fixed sanitazer and academy

This commit is contained in:
2026-06-05 16:53:20 +02:00
parent 15870ddb1f
commit f89ee937c0
29 changed files with 2444 additions and 1039 deletions

View File

@@ -42,6 +42,7 @@ class RegisteredUserController extends Controller
return view('auth.register', [ return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''), 'prefillEmail' => (string) $request->query('email', ''),
'page_canonical' => route('register'),
'turnstile' => [ 'turnstile' => [
'enabled' => $this->turnstileVerifier->isEnabled(), 'enabled' => $this->turnstileVerifier->isEnabled(),
'siteKey' => $this->turnstileVerifier->siteKey(), 'siteKey' => $this->turnstileVerifier->siteKey(),

View File

@@ -79,6 +79,7 @@ class GroupController extends Controller
{ {
$this->authorize('view', $group); $this->authorize('view', $group);
$section = in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview';
$viewer = $request->user(); $viewer = $request->user();
$group->loadMissing('owner.profile'); $group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer)) $members = collect($this->memberships->mapMembers($group, $viewer))
@@ -89,7 +90,8 @@ class GroupController extends Controller
return Inertia::render('Group/GroupShow', [ return Inertia::render('Group/GroupShow', [
'group' => $groupPayload, 'group' => $groupPayload,
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview', 'section' => $section,
'seo' => $this->seoPayload($group, $section),
'featuredArtworks' => $this->groups->featuredArtworkCards($group), 'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group), 'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer), 'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
@@ -140,4 +142,19 @@ class GroupController extends Controller
{ {
return $this->show($request, $group, 'activity'); return $this->show($request, $group, 'activity');
} }
private function seoPayload(Group $group, string $section): array
{
$canonical = $section === 'overview'
? route('groups.show', ['group' => $group])
: route('groups.section', ['group' => $group, 'section' => $section]);
$sectionLabel = $section === 'overview' ? '' : ' '.ucfirst($section);
return [
'title' => trim($group->name.$sectionLabel.' - Skinbase'),
'description' => $group->headline ?: $group->bio ?: 'Skinbase group',
'canonical' => $canonical,
'og_url' => $canonical,
];
}
} }

View File

@@ -767,6 +767,17 @@ final class AcademyAdminController extends Controller
]; ];
} }
if ($resource === 'challenges') {
return [
'links' => array_filter([
'preview' => $record->exists ? route('academy.challenges.show', ['slug' => $record->slug]) : null,
]),
'coverUploadUrl' => route('api.studio.academy.lessons.media.upload'),
'coverDeleteUrl' => route('api.studio.academy.lessons.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
];
}
if ($resource !== 'lessons') { if ($resource !== 'lessons') {
return []; return [];
} }
@@ -1200,6 +1211,7 @@ final class AcademyAdminController extends Controller
'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'), 'voting_starts_at' => optional($record->voting_starts_at)?->format('Y-m-d\TH:i'),
'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'), 'voting_ends_at' => optional($record->voting_ends_at)?->format('Y-m-d\TH:i'),
'cover_image' => (string) ($record->cover_image ?? ''), 'cover_image' => (string) ($record->cover_image ?? ''),
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($record->cover_image ?? '')),
'prize_text' => (string) ($record->prize_text ?? ''), 'prize_text' => (string) ($record->prize_text ?? ''),
'required_tags' => implode(', ', (array) ($record->required_tags ?? [])), 'required_tags' => implode(', ', (array) ($record->required_tags ?? [])),
'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])), 'allowed_categories' => implode(', ', (array) ($record->allowed_categories ?? [])),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio; namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\News\NewsService; use App\Services\News\NewsService;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -46,6 +48,8 @@ final class StudioNewsController extends Controller
{ {
$this->authorizeNews($request); $this->authorizeNews($request);
$user = $request->user();
return Inertia::render('Studio/StudioNewsEditor', [ return Inertia::render('Studio/StudioNewsEditor', [
'title' => 'Create article', 'title' => 'Create article',
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.', 'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
@@ -61,11 +65,14 @@ final class StudioNewsController extends Controller
'storeUrl' => route('studio.news.store'), 'storeUrl' => route('studio.news.store'),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'entitySearchUrl' => route('studio.news.entity-search'), 'entitySearchUrl' => route('studio.news.entity-search'),
'categoriesUrl' => route('studio.news.categories'), 'categoriesUrl' => route('studio.news.categories'),
'tagsUrl' => route('studio.news.tags'), 'tagsUrl' => route('studio.news.tags'),
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null, 'defaultAuthor' => $this->mapDefaultAuthor($user),
'defaultPublishedAt' => now()->format('Y-m-d\TH:i'),
]); ]);
} }
@@ -96,6 +103,8 @@ final class StudioNewsController extends Controller
'relationTypeOptions' => $this->news->relationTypeOptions(), 'relationTypeOptions' => $this->news->relationTypeOptions(),
'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverUploadUrl' => route('api.studio.news.media.upload'),
'coverDeleteUrl' => route('api.studio.news.media.destroy'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'),
'bodyMediaUploadUrl' => route('api.studio.news.media.upload'),
'bodyMediaDeleteUrl' => route('api.studio.news.media.destroy'),
'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'), 'coverCdnBaseUrl' => rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/'),
'updateUrl' => route('studio.news.update', ['article' => $article->id]), 'updateUrl' => route('studio.news.update', ['article' => $article->id]),
'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]), 'destroyUrl' => route('studio.news.destroy', ['article' => $article->id]),
@@ -250,6 +259,29 @@ final class StudioNewsController extends Controller
]); ]);
} }
private function mapDefaultAuthor(mixed $user): ?array
{
if (! $user instanceof User) {
return null;
}
$user->loadMissing('profile');
return [
'id' => (int) $user->id,
'entity_type' => 'user',
'entity_label' => 'User',
'title' => (string) ($user->name ?: $user->username),
'subtitle' => $user->username ? '@' . $user->username : null,
'description' => Str::limit(trim((string) ($user->profile?->bio ?? '')), 120),
'url' => $user->username ? route('profile.show', ['username' => $user->username]) : null,
'image' => null,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 96),
'context_label' => 'Profile',
'meta' => [],
];
}
public function storeCategory(Request $request): RedirectResponse public function storeCategory(Request $request): RedirectResponse
{ {
$this->authorizeNews($request); $this->authorizeNews($request);

View File

@@ -50,7 +50,8 @@ class TopAuthorsController extends Controller
}); });
$page_title = 'Top Creators'; $page_title = 'Top Creators';
$page_canonical = route('creators.top');
return view('web.authors.top', compact('page_title', 'authors', 'metric')); return view('web.authors.top', compact('page_title', 'page_canonical', 'authors', 'metric'));
} }
} }

View File

@@ -65,6 +65,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Trending Artworks', 'page_title' => 'Trending Artworks',
'page_canonical' => $this->canonicalRoute('discover.trending'),
'section' => 'trending', 'section' => 'trending',
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.', 'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
'icon' => 'fa-fire', 'icon' => 'fa-fire',
@@ -97,6 +98,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Rising Now', 'page_title' => 'Rising Now',
'page_canonical' => $this->canonicalRoute('discover.rising'),
'section' => 'rising', 'section' => 'rising',
'description' => 'Fastest growing artworks right now.', 'description' => 'Fastest growing artworks right now.',
'icon' => 'fa-rocket', 'icon' => 'fa-rocket',
@@ -119,6 +121,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Fresh Uploads', 'page_title' => 'Fresh Uploads',
'page_canonical' => $this->canonicalRoute('discover.fresh'),
'section' => 'fresh', 'section' => 'fresh',
'description' => 'The latest artworks just uploaded to Skinbase.', 'description' => 'The latest artworks just uploaded to Skinbase.',
'icon' => 'fa-bolt', 'icon' => 'fa-bolt',
@@ -138,6 +141,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Top Rated Artworks', 'page_title' => 'Top Rated Artworks',
'page_canonical' => $this->canonicalRoute('discover.top-rated'),
'section' => 'top-rated', 'section' => 'top-rated',
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.', 'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
'icon' => 'fa-medal', 'icon' => 'fa-medal',
@@ -157,6 +161,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $results, 'artworks' => $results,
'page_title' => 'Most Downloaded', 'page_title' => 'Most Downloaded',
'page_canonical' => $this->canonicalRoute('discover.most-downloaded'),
'section' => 'most-downloaded', 'section' => 'most-downloaded',
'description' => 'All-time most downloaded artworks on Skinbase.', 'description' => 'All-time most downloaded artworks on Skinbase.',
'icon' => 'fa-download', 'icon' => 'fa-download',
@@ -178,9 +183,9 @@ final class DiscoverController extends Controller
'categories:id,name,slug,content_type_id,parent_id,sort_order', 'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name', 'categories.contentType:id,slug,name',
]) ])
->whereRaw('MONTH(published_at) = ?', [$today->month]) ->whereMonth('published_at', $today->month)
->whereRaw('DAY(published_at) = ?', [$today->day]) ->whereDay('published_at', $today->day)
->whereRaw('YEAR(published_at) < ?', [$today->year]) ->whereYear('published_at', '<', $today->year)
->orderMissingThumbnailsLast() ->orderMissingThumbnailsLast()
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($perPage) ->paginate($perPage)
@@ -191,6 +196,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $artworks, 'artworks' => $artworks,
'page_title' => 'On This Day', 'page_title' => 'On This Day',
'page_canonical' => $this->canonicalRoute('discover.on-this-day'),
'section' => 'on-this-day', 'section' => 'on-this-day',
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.', 'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
'icon' => 'fa-calendar-day', 'icon' => 'fa-calendar-day',
@@ -246,6 +252,7 @@ final class DiscoverController extends Controller
return view('web.creators.rising', [ return view('web.creators.rising', [
'creators' => $creators, 'creators' => $creators,
'page_title' => 'Rising Creators — Skinbase', 'page_title' => 'Rising Creators — Skinbase',
'page_canonical' => $this->canonicalRoute('creators.rising'),
]); ]);
} }
@@ -327,6 +334,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => collect(), 'artworks' => collect(),
'page_title' => 'Following Feed', 'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following', 'section' => 'following',
'description' => 'Follow some creators to see their work here.', 'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group', 'icon' => 'fa-user-group',
@@ -366,6 +374,7 @@ final class DiscoverController extends Controller
return view('web.discover.index', [ return view('web.discover.index', [
'artworks' => $artworks, 'artworks' => $artworks,
'page_title' => 'Following Feed', 'page_title' => 'Following Feed',
'page_canonical' => $this->canonicalRoute('discover.following'),
'section' => 'following', 'section' => 'following',
'description' => 'The latest artworks from creators you follow.', 'description' => 'The latest artworks from creators you follow.',
'icon' => 'fa-user-group', 'icon' => 'fa-user-group',
@@ -388,6 +397,11 @@ final class DiscoverController extends Controller
return ! $items || $items->isEmpty(); return ! $items || $items->isEmpty();
} }
private function canonicalRoute(string $routeName): string
{
return route($routeName);
}
private function paginatorHasNoRisingMomentum($paginator): bool private function paginatorHasNoRisingMomentum($paginator): bool
{ {
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) { if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {

View File

@@ -37,11 +37,22 @@ class ContentSanitizer
'p', 'br', 'strong', 'em', 'code', 'pre', 'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del', 'a', 'ul', 'ol', 'li', 'blockquote', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Image and embed-related tags used by the rich editor
'figure', 'figcaption', 'img', 'picture', 'source', 'iframe',
// Basic structural/inline helpers sometimes produced by embeds
'div', 'span'
]; ];
// Allowed attributes per tag // Allowed attributes per tag
private const ALLOWED_ATTRS = [ private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'], 'a' => ['href', 'title', 'rel', 'target'],
'img' => ['src', 'srcset', 'sizes', 'alt', 'title', 'loading', 'decoding', 'width', 'height', 'style', 'class', 'data-width'],
'source' => ['srcset', 'src', 'type', 'media', 'sizes'],
'figure' => ['class', 'data-rich-image', 'data-platform', 'data-video-embed', 'data-social-embed', 'data-artwork-embed'],
'figcaption' => ['class'],
'iframe' => ['src', 'title', 'loading', 'frameborder', 'allow', 'allowfullscreen', 'referrerpolicy'],
'div' => ['class', 'data-href', 'data-show-text'],
'span' => ['class'],
]; ];
private static ?MarkdownConverter $converter = null; private static ?MarkdownConverter $converter = null;
@@ -261,14 +272,82 @@ class ContentSanitizer
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? []; $allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = []; $attrsToRemove = [];
foreach ($child->attributes as $attr) { foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) { $name = $attr->nodeName;
$attrsToRemove[] = $attr->nodeName;
// Allow data-* attributes and class on allowed tags
if (str_starts_with($name, 'data-') || $name === 'class') {
continue;
}
if (! in_array($name, $allowedAttrs, true)) {
$attrsToRemove[] = $name;
} }
} }
foreach ($attrsToRemove as $attrName) { foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName); $child->removeAttribute($attrName);
} }
// Validate URL-like attributes for image/source/iframe
if ($tag === 'img') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
// Validate srcset: ensure each URL is safe; if not, remove the attribute
$srcset = $child->getAttribute('srcset');
if ($srcset) {
$parts = array_map('trim', explode(',', $srcset));
$valid = true;
foreach ($parts as $part) {
if ($part === '') {
continue;
}
// Each part: "url [descriptor]"
$pieces = preg_split('/\s+/', $part);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$child->removeAttribute('srcset');
}
}
}
if ($tag === 'source') {
$src = $child->getAttribute('src') ?: $child->getAttribute('srcset');
if ($src) {
// For srcset allow comma-separated list; validate each
$values = array_map('trim', explode(',', $src));
$valid = true;
foreach ($values as $v) {
if ($v === '') continue;
$pieces = preg_split('/\s+/', $v);
$url = $pieces[0] ?? '';
if ($url !== '' && ! static::isSafeUrl($url)) {
$valid = false;
break;
}
}
if (! $valid) {
$toUnwrap[] = $child;
continue;
}
}
}
if ($tag === 'iframe') {
$src = $child->getAttribute('src');
if ($src && ! static::isSafeUrl($src)) {
$toUnwrap[] = $child;
continue;
}
}
// Force external links to be safe // Force external links to be safe
if ($tag === 'a') { if ($tag === 'a') {
if (! $allowLinks) { if (! $allowLinks) {

View File

@@ -761,12 +761,12 @@ final class HomepageService
/** /**
* Latest 5 news posts from the forum news category. * Latest 5 news posts from the forum news category.
*/ */
public function getNews(int $limit = 5): array public function getNews(int $limit = 10): array
{ {
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array { return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try { try {
$articles = NewsArticle::query() $articles = NewsArticle::query()
->with('category') ->with(['category', 'author'])
->published() ->published()
->editorialOrder() ->editorialOrder()
->limit($limit) ->limit($limit)
@@ -774,13 +774,23 @@ final class HomepageService
if ($articles->isNotEmpty()) { if ($articles->isNotEmpty()) {
return $articles->map(fn (NewsArticle $article) => [ return $articles->map(fn (NewsArticle $article) => [
'id' => $article->id, 'id' => $article->id,
'title' => $article->title, 'title' => $article->title,
'date' => $article->published_at, 'date' => $article->published_at,
'url' => route('news.show', ['slug' => $article->slug]), 'url' => route('news.show', ['slug' => $article->slug]),
'eyebrow' => $article->category?->name ?: $article->type_label, 'eyebrow' => $article->category?->name ?: $article->type_label,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120), 'type' => $article->type ?? null,
])->values()->all(); 'type_label' => $article->type_label ?? null,
'category' => $article->category ? ['name' => $article->category->name, 'slug' => $article->category->slug] : null,
'is_featured' => (bool) ($article->is_featured ?? false),
'is_pinned' => (bool) ($article->is_pinned ?? false),
'cover_url' => $article->cover_url ?? null,
'cover_mobile_url' => $article->cover_mobile_url ?? null,
'cover_srcset' => $article->cover_srcset ?? null,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 135),
'author' => $article->author ? ['name' => $article->author->name ?? $article->author->username, 'username' => $article->author->username ?? null] : null,
'views' => isset($article->views) ? (int) $article->views : 0,
])->values()->all();
} }
$items = DB::table('forum_threads as t') $items = DB::table('forum_threads as t')

View File

@@ -61,12 +61,14 @@ final class RSSFeedBuilder
string $channelLink, string $channelLink,
string $feedUrl, string $feedUrl,
Collection $items, Collection $items,
?string $canonicalUrl = null,
): Response { ): Response {
$xml = view('rss.channel', [ $xml = view('rss.channel', [
'channelTitle' => trim($channelTitle) . ' — Skinbase', 'channelTitle' => trim($channelTitle) . ' — Skinbase',
'channelDescription' => $channelDescription, 'channelDescription' => $channelDescription,
'channelLink' => $channelLink, 'channelLink' => $channelLink,
'feedUrl' => $feedUrl, 'feedUrl' => $feedUrl,
'canonicalUrl' => $canonicalUrl ?: $feedUrl,
'items' => $items, 'items' => $items,
'buildDate' => now()->toRfc2822String(), 'buildDate' => now()->toRfc2822String(),
])->render(); ])->render();

View File

@@ -18,7 +18,7 @@ import "./vendor-tooltip-CIQaDNlG.js";
import "node:process"; import "node:process";
import "node:path"; import "node:path";
import "node:url"; import "node:url";
import "./vendor-realtime-Koiu-_pw.js"; import "./vendor-realtime-DYEIbD6w.js";
import "buffer"; import "buffer";
import "child_process"; import "child_process";
import "net"; import "net";

View File

@@ -1,17 +1,17 @@
import require$$0 from "util"; import require$$0 from "util";
import stream from "stream"; import stream from "stream";
import require$$4 from "https";
import require$$5 from "url"; import require$$5 from "url";
import require$$6 from "fs"; import require$$6 from "fs";
import require$$1 from "crypto"; import require$$1 from "crypto";
import require$$4$2 from "assert"; import require$$4$2 from "assert";
import require$$1$1 from "buffer"; import require$$1$1 from "buffer";
import require$$2 from "child_process"; import require$$2 from "child_process";
import require$$4$1 from "events";
import require$$8 from "net"; import require$$8 from "net";
import require$$10 from "tls"; import require$$10 from "tls";
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js"; import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js";
import require$$4$1 from "events";
import require$$3 from "http"; import require$$3 from "http";
import require$$4 from "https";
class u { class u {
constructor() { constructor() {
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated"; this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";

View File

@@ -14,10 +14,10 @@
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [], "\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [], "\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [ "\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [ "\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [], "\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [], "\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
@@ -97,46 +97,46 @@
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
], ],
"\u0000assert?commonjs-external": [ "\u0000assert?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000buffer?commonjs-external": [ "\u0000buffer?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000child_process?commonjs-external": [ "\u0000child_process?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000commonjsHelpers.js": [ "\u0000commonjsHelpers.js": [
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
], ],
"\u0000crypto?commonjs-external": [ "\u0000crypto?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000events?commonjs-external": [ "\u0000events?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000fs?commonjs-external": [ "\u0000fs?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000http?commonjs-external": [ "\u0000http?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000https?commonjs-external": [ "\u0000https?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000net?commonjs-external": [ "\u0000net?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000stream?commonjs-external": [ "\u0000stream?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000tls?commonjs-external": [ "\u0000tls?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000url?commonjs-external": [ "\u0000url?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"\u0000util?commonjs-external": [ "\u0000util?commonjs-external": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/@emoji-mart/data/sets/15/native.json": [ "node_modules/@emoji-mart/data/sets/15/native.json": [
"/build/assets/emoji-data-4xGXbtDn.js" "/build/assets/emoji-data-4xGXbtDn.js"
@@ -1035,7 +1035,7 @@
"node_modules/inline-style-parser/cjs/index.js": [], "node_modules/inline-style-parser/cjs/index.js": [],
"node_modules/is-plain-obj/index.js": [], "node_modules/is-plain-obj/index.js": [],
"node_modules/laravel-echo/dist/echo.js": [ "node_modules/laravel-echo/dist/echo.js": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/linkifyjs/dist/linkify.mjs": [ "node_modules/linkifyjs/dist/linkify.mjs": [
"/build/assets/vendor-tiptap-DRFaxGEb.js" "/build/assets/vendor-tiptap-DRFaxGEb.js"
@@ -1935,7 +1935,7 @@
], ],
"node_modules/proxy-from-env/index.js": [], "node_modules/proxy-from-env/index.js": [],
"node_modules/pusher-js/dist/node/pusher.js": [ "node_modules/pusher-js/dist/node/pusher.js": [
"/build/assets/vendor-realtime-Koiu-_pw.js" "/build/assets/vendor-realtime-DYEIbD6w.js"
], ],
"node_modules/qs/lib/formats.js": [], "node_modules/qs/lib/formats.js": [],
"node_modules/qs/lib/index.js": [], "node_modules/qs/lib/index.js": [],
@@ -2055,6 +2055,7 @@
"resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx": [], "resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx": [],
"resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx": [], "resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx": [],
"resources/js/Pages/Admin/Academy/Billing.jsx": [], "resources/js/Pages/Admin/Academy/Billing.jsx": [],
"resources/js/Pages/Admin/Academy/ChallengeEditor.jsx": [],
"resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [], "resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [],
"resources/js/Pages/Admin/Academy/CourseEditor.jsx": [], "resources/js/Pages/Admin/Academy/CourseEditor.jsx": [],
"resources/js/Pages/Admin/Academy/CrudForm.jsx": [], "resources/js/Pages/Admin/Academy/CrudForm.jsx": [],
@@ -2251,7 +2252,7 @@
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [], "resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
"resources/js/components/artwork/ArtworkShareButton.jsx": [], "resources/js/components/artwork/ArtworkShareButton.jsx": [],
"resources/js/components/artwork/ArtworkShareModal.jsx": [ "resources/js/components/artwork/ArtworkShareModal.jsx": [
"/build/assets/ArtworkShareModal-BPM8yel5.js" "/build/assets/ArtworkShareModal-BI8kkaqs.js"
], ],
"resources/js/components/artwork/ArtworkTags.jsx": [], "resources/js/components/artwork/ArtworkTags.jsx": [],
"resources/js/components/artwork/AuthorBioPopover.jsx": [], "resources/js/components/artwork/AuthorBioPopover.jsx": [],

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,639 @@
import React, { useMemo, useRef, useState } from 'react'
import { Head, Link, router, useForm } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
import ShareToast from '../../../components/ui/ShareToast'
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
const CHALLENGE_EDITOR_TABS = [
{
id: 'overview',
label: 'Overview',
description: 'Title, slug, access, and the compact summary shown in academy challenge lists.',
icon: 'fa-compass-drafting',
sections: ['challenge-identity'],
},
{
id: 'brief',
label: 'Brief',
description: 'Write the creative objective, rules, prizes, required tags, and eligible categories.',
icon: 'fa-bullseye',
sections: ['challenge-brief', 'challenge-rules'],
},
{
id: 'media',
label: 'Media',
description: 'Upload the hero cover and tune the visual used on challenge cards.',
icon: 'fa-image',
sections: ['challenge-media'],
},
{
id: 'timeline',
label: 'Timeline',
description: 'Manage submission and voting windows without mixing them into editorial copy.',
icon: 'fa-calendar-days',
sections: ['challenge-timeline'],
},
{
id: 'publish',
label: 'Publish',
description: 'Control status, visibility, featured placement, and the final public preview.',
icon: 'fa-rocket-launch',
sections: ['challenge-publishing', 'challenge-preview'],
},
]
const CHALLENGE_FIELD_TAB_MAP = {
title: 'overview',
slug: 'overview',
excerpt: 'overview',
access_level: 'overview',
description: 'brief',
brief: 'brief',
rules: 'brief',
prize_text: 'brief',
required_tags: 'brief',
allowed_categories: 'brief',
cover_image: 'media',
starts_at: 'timeline',
ends_at: 'timeline',
voting_starts_at: 'timeline',
voting_ends_at: 'timeline',
status: 'publish',
featured: 'publish',
active: 'publish',
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
function FieldError({ message }) {
if (!message) return null
return <p className="text-xs text-rose-300">{message}</p>
}
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '' }) {
const toneClass = tone === 'feature'
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.15),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
: 'bg-white/[0.03]'
return (
<section id={id} className={`min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim()}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
<div className="mt-5">{children}</div>
</section>
)
}
function EditorWorkspaceTabs({ activeTab, onChange, errorCounts }) {
const activeMeta = CHALLENGE_EDITOR_TABS.find((tab) => tab.id === activeTab) || CHALLENGE_EDITOR_TABS[0]
return (
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Challenge editor sections">
{CHALLENGE_EDITOR_TABS.map((tab) => {
const isActive = tab.id === activeTab
const errorCount = Number(errorCounts?.[tab.id] || 0)
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`challenge-editor-panel-${tab.id}`}
id={`challenge-editor-tab-${tab.id}`}
onClick={() => onChange(tab.id)}
className={[
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
isActive
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
].join(' ')}
>
<i className={`fa-solid ${tab.icon} text-xs`} />
<span>{tab.label}</span>
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
</button>
)
})}
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
{activeMeta.sections.map((section) => (
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('challenge-', '').replace(/-/g, ' ')}</span>
))}
</div>
</div>
</div>
)
}
function TextField({ label, value, onChange, error, hint, ...rest }) {
return (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
<FieldError message={error} />
</label>
)
}
function TextAreaField({ label, value, onChange, error, rows = 4, hint, placeholder }) {
return (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
<textarea value={value ?? ''} onChange={onChange} rows={rows} placeholder={placeholder} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 leading-7 text-white outline-none placeholder:text-slate-600" />
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
<FieldError message={error} />
</label>
)
}
function ToggleField({ label, checked, onChange, help, error }) {
return (
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
<i className="fa-solid fa-check" />
</span>
<span className="min-w-0">
<span className="block text-base font-semibold tracking-[-0.02em] text-white">{label}</span>
{help ? <span className="mt-1 block text-sm leading-6 text-slate-300">{help}</span> : null}
<FieldError message={error} />
</span>
</label>
)
}
function PlainTextPreview({ title, value, fallback }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{title}</p>
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">
{String(value || '').trim() || fallback}
</div>
</div>
)
}
function statusMeta(status) {
const normalized = String(status || 'draft')
const labels = {
draft: 'Draft',
scheduled: 'Scheduled',
active: 'Active',
voting: 'Voting',
completed: 'Completed',
archived: 'Archived',
}
const classes = {
draft: 'border-white/10 bg-white/[0.04] text-slate-300',
scheduled: 'border-fuchsia-300/20 bg-fuchsia-300/10 text-fuchsia-100',
active: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
voting: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
completed: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
archived: 'border-slate-300/15 bg-slate-300/8 text-slate-300',
}
return {
label: labels[normalized] || normalized,
className: classes[normalized] || classes.draft,
}
}
function slugifyChallengeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 180)
}
function normalizeList(value) {
return String(value || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeAssetPreview(value, cdnBaseUrl) {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
}
function stripPlainText(value) {
return String(value || '').replace(/\s+/g, ' ').trim()
}
function countWords(value) {
const text = stripPlainText(value)
return text ? text.split(/\s+/).length : 0
}
function formatDateLabel(value, fallback = 'Not set') {
if (!value) return fallback
const date = new Date(value)
if (Number.isNaN(date.getTime())) return fallback
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function daysBetween(start, end) {
const startDate = start ? new Date(start) : null
const endDate = end ? new Date(end) : null
if (!startDate || !endDate || Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return null
}
const diff = endDate.getTime() - startDate.getTime()
if (diff < 0) return 'Dates need review'
const days = Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)))
return `${days} day${days === 1 ? '' : 's'}`
}
function firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
const queue = [errors]
while (queue.length > 0) {
const current = queue.shift()
if (typeof current === 'string') {
const message = current.trim()
if (message) return message
continue
}
if (Array.isArray(current)) {
queue.push(...current)
continue
}
if (current && typeof current === 'object') {
queue.push(...Object.values(current))
}
}
return fallback
}
function firstChallengeErrorTab(errors) {
const firstKey = Object.keys(errors || {})[0]
if (!firstKey) return null
const baseKey = firstKey.split('.')[0]
return CHALLENGE_FIELD_TAB_MAP[baseKey] || null
}
function challengeTabErrorCounts(errors) {
const counts = {}
Object.keys(errors || {}).forEach((key) => {
const tabId = CHALLENGE_FIELD_TAB_MAP[key.split('.')[0]]
if (!tabId) return
counts[tabId] = Number(counts[tabId] || 0) + 1
})
return counts
}
function buildChallengePayload(data) {
return {
...data,
required_tags: normalizeList(data.required_tags),
allowed_categories: normalizeList(data.allowed_categories),
}
}
export default function ChallengeEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
const form = useForm(record)
const slugTouchedRef = useRef(String(record?.slug || '').trim() !== '')
const [activeTab, setActiveTab] = useState('overview')
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record?.cover_image_url || normalizeAssetPreview(record?.cover_image, editorContext.coverCdnBaseUrl))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
const accessField = getField(fields, 'access_level')
const statusField = getField(fields, 'status')
const status = statusMeta(form.data.status)
const requiredTags = useMemo(() => normalizeList(form.data.required_tags), [form.data.required_tags])
const allowedCategories = useMemo(() => normalizeList(form.data.allowed_categories), [form.data.allowed_categories])
const briefWordCount = countWords(`${form.data.description || ''} ${form.data.brief || ''} ${form.data.rules || ''}`)
const submissionWindow = daysBetween(form.data.starts_at, form.data.ends_at)
const votingWindow = daysBetween(form.data.voting_starts_at, form.data.voting_ends_at)
const publicPathPreview = `/academy/challenges/${form.data.slug || 'challenge-slug'}`
const errorCounts = challengeTabErrorCounts(form.errors)
const sectionClassName = (sectionId) => {
const tab = CHALLENGE_EDITOR_TABS.find((item) => item.sections.includes(sectionId))
return tab && tab.id !== activeTab ? 'hidden' : ''
}
const showToast = (message, variant = 'error') => {
setToast({
id: Date.now() + Math.random(),
visible: true,
message,
variant,
})
}
const setTitle = (nextTitle) => {
form.setData('title', nextTitle)
if (!slugTouchedRef.current) {
form.setData('slug', slugifyChallengeTitle(nextTitle))
}
}
const handleManualCoverChange = (nextValue) => {
setStagedCoverPath('')
form.setData('cover_image', nextValue)
setCoverPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
}
const submit = (event) => {
event.preventDefault()
const payload = buildChallengePayload(form.data)
form.transform(() => payload)
const submitOptions = {
preserveScroll: true,
onError: (errors) => {
const nextTab = firstChallengeErrorTab(errors)
if (nextTab) {
setActiveTab(nextTab)
}
showToast(firstErrorMessage(errors), 'error')
},
onFinish: () => form.transform((data) => data),
}
if (method === 'patch') {
form.patch(submitUrl, submitOptions)
return
}
form.post(submitUrl, submitOptions)
}
const deleteChallenge = () => {
if (!destroyUrl) return
if (!window.confirm('Delete this challenge?')) return
router.delete(destroyUrl)
}
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin - ${title}`} />
<form onSubmit={submit} className="space-y-6 pb-16">
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to challenges</Link>
<span>{destroyUrl ? 'Edit challenge' : 'New challenge'}</span>
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy challenge'}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{form.data.excerpt || 'Shape a guided creative brief with clear rules, dates, tags, and a public-ready preview.'}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] ${status.className}`}>{status.label}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{form.data.access_level || 'free'}</span>
{editorContext?.links?.preview ? <a href={editorContext.links.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">Preview public page</a> : null}
</div>
</div>
<div className="grid gap-4 px-5 py-5 md:grid-cols-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Submission window</p>
<p className="mt-2 text-sm font-semibold text-white">{submissionWindow || 'Not scheduled'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Voting window</p>
<p className="mt-2 text-sm font-semibold text-white">{votingWindow || 'Not scheduled'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required tags</p>
<p className="mt-2 text-sm font-semibold text-white">{requiredTags.length}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Brief copy</p>
<p className="mt-2 text-sm font-semibold text-white">{briefWordCount.toLocaleString()} words</p>
</div>
</div>
</section>
<EditorWorkspaceTabs activeTab={activeTab} onChange={setActiveTab} errorCounts={errorCounts} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0 space-y-6">
<SectionCard id="challenge-identity" eyebrow="Challenge identity" title="Name and positioning" description="Keep the title clear, the slug readable, and the card summary useful for academy discovery." className={sectionClassName('challenge-identity')}>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Title" value={form.data.title || ''} onChange={(event) => setTitle(event.target.value)} error={form.errors.title} maxLength={180} placeholder="Creator brief: cinematic wallpaper challenge" />
<label className="grid gap-2 text-sm text-slate-300">
<span className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<button type="button" onClick={() => {
slugTouchedRef.current = false
form.setData('slug', slugifyChallengeTitle(form.data.title))
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
</span>
<input value={form.data.slug || ''} onChange={(event) => {
slugTouchedRef.current = String(event.target.value).trim() !== ''
form.setData('slug', event.target.value)
}} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" maxLength={180} placeholder="cinematic-wallpaper-challenge" />
<FieldError message={form.errors.slug} />
</label>
</div>
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short public summary used on challenge cards and academy home rails." />
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect label={accessField?.label || 'Access'} value={form.data.access_level || ''} onChange={(nextValue) => form.setData('access_level', String(nextValue || ''))} options={accessField?.options || []} searchable={false} className="bg-black/20" error={form.errors.access_level} />
<TextField label="Public path" value={publicPathPreview} readOnly hint="Generated from the slug. Published challenge pages use this path." />
</div>
</SectionCard>
<SectionCard id="challenge-brief" eyebrow="Creative brief" title="Brief and creator direction" description="Write the task in a way creators can execute without needing extra staff notes." tone="feature" className={sectionClassName('challenge-brief')}>
<TextAreaField label="Description" value={form.data.description || ''} onChange={(event) => form.setData('description', event.target.value)} error={form.errors.description} rows={6} hint="Overview copy for the challenge page. Plain text is safest here because public challenge pages preserve line breaks." placeholder="Introduce the creative goal, theme, and expected output." />
<TextAreaField label="Brief" value={form.data.brief || ''} onChange={(event) => form.setData('brief', event.target.value)} error={form.errors.brief} rows={10} hint="The main actionable brief. Use line breaks for steps, constraints, or judging criteria." placeholder="Objective, deliverable, style direction, and acceptance notes." />
</SectionCard>
<SectionCard id="challenge-rules" eyebrow="Rules and eligibility" title="Requirements and rewards" description="Keep challenge operations close to the brief so staff can audit everything before launch." className={sectionClassName('challenge-rules')}>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)]">
<TextAreaField label="Rules" value={form.data.rules || ''} onChange={(event) => form.setData('rules', event.target.value)} error={form.errors.rules} rows={10} hint="Submission rules, judging notes, restrictions, and moderation expectations." />
<div className="grid gap-4">
<TextField label="Prize text" value={form.data.prize_text || ''} onChange={(event) => form.setData('prize_text', event.target.value)} error={form.errors.prize_text} maxLength={180} placeholder="Featured placement plus Academy badge" />
<TextAreaField label="Required tags" value={form.data.required_tags || ''} onChange={(event) => form.setData('required_tags', event.target.value)} error={form.errors.required_tags} rows={4} hint="Comma-separated or one per line. Saved as an array." placeholder="academy-challenge, cinematic, wallpaper" />
<TextAreaField label="Allowed categories" value={form.data.allowed_categories || ''} onChange={(event) => form.setData('allowed_categories', event.target.value)} error={form.errors.allowed_categories} rows={4} hint="Comma-separated or one per line. Saved as an array." placeholder="Wallpapers, Skins, Worlds" />
</div>
</div>
</SectionCard>
<SectionCard id="challenge-media" eyebrow="Visual system" title="Cover media" description="Upload clean landscape imagery that works in public challenge cards and the challenge detail hero." className={sectionClassName('challenge-media')}>
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
<div className="space-y-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_image}
previewUrl={coverPreviewUrl}
emptyLabel="Challenge cover"
helperText="Use a landscape JPG, PNG, or WEBP. Minimum upload is 600x315; a 16:9 cover will crop best across academy surfaces."
uploadUrl={editorContext.coverUploadUrl}
deleteUrl={editorContext.coverDeleteUrl}
isTemporaryValue={Boolean(stagedCoverPath) && stagedCoverPath === form.data.cover_image}
onChange={({ path, url }) => {
setStagedCoverPath(path || '')
form.setData('cover_image', path || '')
setCoverPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
}}
/>
<FieldError message={form.errors.cover_image} />
<TextField label="Advanced cover path or URL" value={form.data.cover_image || ''} onChange={(event) => handleManualCoverChange(event.target.value)} error={form.errors.cover_image} placeholder="Optional external URL or stored object path" />
</div>
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
{coverPreviewUrl ? (
<img src={coverPreviewUrl} alt="Challenge cover preview" className="h-72 w-full object-cover" />
) : (
<div className="flex h-72 items-center justify-center px-6 text-center text-sm text-slate-500">No challenge cover selected yet.</div>
)}
</div>
</div>
</SectionCard>
<SectionCard id="challenge-timeline" eyebrow="Timeline" title="Submission and voting windows" description="Set the dates that move the challenge from scheduled, to active, to voting, then completed." className={sectionClassName('challenge-timeline')}>
<div className="grid gap-4 md:grid-cols-2">
<DateTimePicker label="Starts at" value={form.data.starts_at || ''} onChange={(nextValue) => form.setData('starts_at', nextValue || '')} error={form.errors.starts_at} clearable className="bg-black/20" />
<DateTimePicker label="Ends at" value={form.data.ends_at || ''} onChange={(nextValue) => form.setData('ends_at', nextValue || '')} error={form.errors.ends_at} clearable className="bg-black/20" />
<DateTimePicker label="Voting starts at" value={form.data.voting_starts_at || ''} onChange={(nextValue) => form.setData('voting_starts_at', nextValue || '')} error={form.errors.voting_starts_at} clearable className="bg-black/20" />
<DateTimePicker label="Voting ends at" value={form.data.voting_ends_at || ''} onChange={(nextValue) => form.setData('voting_ends_at', nextValue || '')} error={form.errors.voting_ends_at} clearable className="bg-black/20" />
</div>
</SectionCard>
<SectionCard id="challenge-publishing" eyebrow="Publishing" title="Status and visibility" description="Choose how this challenge behaves in academy discovery and whether it appears in featured placements." className={sectionClassName('challenge-publishing')}>
<div className="grid gap-4 md:grid-cols-3">
<NovaSelect label={statusField?.label || 'Status'} value={form.data.status || ''} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={statusField?.options || []} searchable={false} className="bg-black/20" error={form.errors.status} />
<ToggleField label="Featured" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Promote this challenge on academy rails." error={form.errors.featured} />
<ToggleField label="Active" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Inactive challenges stay hidden even when their status is public." error={form.errors.active} />
</div>
</SectionCard>
<SectionCard id="challenge-preview" eyebrow="Preview" title="Public-facing snapshot" description="Scan the challenge card, brief, rules, tags, and timeline before saving." tone="feature" className={sectionClassName('challenge-preview')}>
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
{coverPreviewUrl ? (
<img src={coverPreviewUrl} alt="Challenge preview" className="h-64 w-full object-cover" />
) : (
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No cover image selected yet.</div>
)}
</div>
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
<div className="flex flex-wrap gap-2">
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${status.className}`}>{status.label}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{form.data.access_level || 'free'}</span>
{form.data.featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span> : null}
</div>
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy challenge'}</h3>
<p className="mt-3 text-sm leading-7 text-slate-300">{form.data.excerpt || 'Add a short challenge summary to explain what creators are building.'}</p>
{form.data.prize_text ? <p className="mt-4 rounded-2xl border border-[#f39a24]/20 bg-[#f39a24]/10 px-4 py-3 text-sm font-semibold text-[#ffd5cd]">{form.data.prize_text}</p> : null}
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<PlainTextPreview title="Brief preview" value={form.data.brief || form.data.description} fallback="The challenge brief is still empty." />
<PlainTextPreview title="Rules preview" value={form.data.rules} fallback="No special rules posted yet." />
</div>
</SectionCard>
</div>
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
<SectionCard eyebrow="At a glance" title="Challenge summary" description="A compact view of the details editors usually check before launch.">
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Public path</p>
<p className="mt-2 break-all text-sm font-semibold text-white">{publicPathPreview}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
<p className="mt-2 text-sm font-semibold text-white">{status.label}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Cover</p>
<p className="mt-2 text-sm font-semibold text-white">{coverPreviewUrl ? 'Ready' : 'Missing'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Submissions</p>
<p className="mt-2 text-sm font-semibold text-white">{formatDateLabel(form.data.starts_at)} to {formatDateLabel(form.data.ends_at)}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Voting</p>
<p className="mt-2 text-sm font-semibold text-white">{formatDateLabel(form.data.voting_starts_at)} to {formatDateLabel(form.data.voting_ends_at)}</p>
</div>
</div>
</SectionCard>
<SectionCard eyebrow="Requirements" title="Tags and categories" description="A quick scan of the saved arrays before the challenge opens.">
<div className="grid gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required tags</p>
<div className="mt-3 flex flex-wrap gap-2">
{requiredTags.length ? requiredTags.map((tag) => <span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold text-sky-100">{tag}</span>) : <span className="text-sm text-slate-500">No required tags yet.</span>}
</div>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Allowed categories</p>
<div className="mt-3 flex flex-wrap gap-2">
{allowedCategories.length ? allowedCategories.map((category) => <span key={category} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-slate-200">{category}</span>) : <span className="text-sm text-slate-500">All categories are allowed unless you add limits.</span>}
</div>
</div>
</div>
</SectionCard>
</div>
</div>
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save challenge'}</button>
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
{destroyUrl ? <button type="button" onClick={deleteChallenge} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
</div>
</form>
<ShareToast
key={toast.id}
message={toast.message}
visible={toast.visible}
variant={toast.variant}
duration={toast.variant === 'error' ? 3200 : 2200}
onHide={() => setToast((current) => ({ ...current, visible: false }))}
/>
</AdminLayout>
)
}

View File

@@ -5,6 +5,7 @@ import AdminLayout from '../../../Layouts/AdminLayout'
import DateTimePicker from '../../../components/ui/DateTimePicker' import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect' import NovaSelect from '../../../components/ui/NovaSelect'
import ShareToast from '../../../components/ui/ShareToast' import ShareToast from '../../../components/ui/ShareToast'
import ChallengeEditor from './ChallengeEditor'
import CourseEditor from './CourseEditor' import CourseEditor from './CourseEditor'
import LessonEditor from './LessonEditor' import LessonEditor from './LessonEditor'
@@ -2291,6 +2292,22 @@ export default function AcademyCrudForm({ resource, title, subtitle, fields, rec
) )
} }
if (resource === 'challenges') {
return (
<ChallengeEditor
title={title}
subtitle={subtitle}
fields={fields}
record={record}
submitUrl={submitUrl}
indexUrl={indexUrl}
destroyUrl={destroyUrl}
method={method}
editorContext={editorContext}
/>
)
}
return ( return (
<GenericEditor <GenericEditor
title={title} title={title}

View File

@@ -873,7 +873,7 @@ export default function GroupShow() {
return ( return (
<div className="relative min-h-screen overflow-hidden pb-16"> <div className="relative min-h-screen overflow-hidden pb-16">
<SeoHead title={`${group.name} - Skinbase`} description={group.headline || group.bio || 'Skinbase group'} /> <SeoHead seo={props.seo || {}} title={`${group.name} - Skinbase`} description={group.headline || group.bio || 'Skinbase group'} />
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"

View File

@@ -23,20 +23,62 @@ export default function HomeNews({ items }) {
</a> </a>
</div> </div>
<div className="divide-y divide-nova-800 overflow-hidden rounded-[24px] border border-white/10 bg-panel"> <div className="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => ( {items.map((item) => (
<a <article
key={item.id} key={item.id}
href={item.url} className="group relative overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]"
className="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
> >
<div className="min-w-0"> <a href={item.url} className="block">
{item.eyebrow ? <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{item.eyebrow}</div> : null} <div className="relative aspect-[16/9] overflow-hidden bg-black/20">
<div className="mt-1 text-sm font-medium text-white line-clamp-2">{item.title}</div> {item.cover_url ? (
{item.excerpt ? <p className="mt-2 text-sm leading-6 text-soft line-clamp-2">{item.excerpt}</p> : null} <img
src={item.cover_mobile_url || item.cover_url}
srcSet={item.cover_srcset || undefined}
sizes={item.cover_srcset ? '(max-width: 767px) 100vw, (max-width: 1279px) 50vw, 390px' : undefined}
alt={item.title}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.98))]" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-[#020611cc] via-transparent to-transparent" />
</div>
</a>
<div className="flex h-full flex-col p-5">
<div className="flex flex-wrap items-center gap-2">
{item.type_label ? (
<span className="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/70 transition group-hover:border-white/20 group-hover:text-white">{item.type_label}</span>
) : null}
{item.category?.name ? (
<span className="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-500/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-200">{item.category.name}</span>
) : null}
{item.is_pinned ? (
<span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100">Pinned</span>
) : item.is_featured ? (
<span className="inline-flex items-center rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-100">Featured</span>
) : null}
{item.date ? <span className="text-[11px] uppercase tracking-[0.16em] text-white/30">{formatDate(item.date)}</span> : null}
</div>
<h3 className="mt-3 text-xl font-semibold leading-tight text-white/95">
<a href={item.url} className="transition hover:text-sky-200">{item.title}</a>
</h3>
{item.excerpt ? <p className="mt-3 flex-1 text-sm leading-7 text-white/55">{item.excerpt}</p> : null}
<div className="mt-4 flex items-center justify-between gap-3 text-sm text-white/40">
<span className="truncate">{item.author?.name || 'Skinbase'}</span>
{typeof item.views === 'number' ? (
<span className="shrink-0 inline-flex items-center gap-1.5">
<i className="fa-regular fa-eye text-[11px]" />
{Number(item.views).toLocaleString()}
</span>
) : null}
</div>
</div> </div>
{item.date ? <span className="flex-shrink-0 text-xs text-soft">{formatDate(item.date)}</span> : null} </article>
</a>
))} ))}
</div> </div>
</section> </section>

View File

@@ -694,7 +694,7 @@ function getDraftValue(source, key, fallback = '') {
return fallback return fallback
} }
function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}) { function buildInitialFormData(article, defaultAuthor, defaultPublishedAt, typeOptions, oldInput = {}) {
return { return {
title: String(getDraftValue(oldInput, 'title', article.title || '')), title: String(getDraftValue(oldInput, 'title', article.title || '')),
slug: String(getDraftValue(oldInput, 'slug', article.slug || '')), slug: String(getDraftValue(oldInput, 'slug', article.slug || '')),
@@ -705,7 +705,7 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
category_id: String(getDraftValue(oldInput, 'category_id', article.category_id ? String(article.category_id) : '')), category_id: String(getDraftValue(oldInput, 'category_id', article.category_id ? String(article.category_id) : '')),
author_id: getDraftValue(oldInput, 'author_id', article.author_id || defaultAuthor?.id || ''), author_id: getDraftValue(oldInput, 'author_id', article.author_id || defaultAuthor?.id || ''),
editorial_status: String(getDraftValue(oldInput, 'editorial_status', article.editorial_status || 'draft')), editorial_status: String(getDraftValue(oldInput, 'editorial_status', article.editorial_status || 'draft')),
published_at: String(getDraftValue(oldInput, 'published_at', article.published_at ? String(article.published_at).slice(0, 16) : '')), published_at: String(getDraftValue(oldInput, 'published_at', article.published_at ? String(article.published_at).slice(0, 16) : (defaultPublishedAt || ''))),
is_featured: parseBooleanish(getDraftValue(oldInput, 'is_featured', Boolean(article.is_featured))), is_featured: parseBooleanish(getDraftValue(oldInput, 'is_featured', Boolean(article.is_featured))),
is_pinned: parseBooleanish(getDraftValue(oldInput, 'is_pinned', Boolean(article.is_pinned))), is_pinned: parseBooleanish(getDraftValue(oldInput, 'is_pinned', Boolean(article.is_pinned))),
comments_enabled: parseBooleanish(getDraftValue(oldInput, 'comments_enabled', article.id ? Boolean(article.comments_enabled) : true)), comments_enabled: parseBooleanish(getDraftValue(oldInput, 'comments_enabled', article.id ? Boolean(article.comments_enabled) : true)),
@@ -952,7 +952,6 @@ async function loadNewsMarkdownTurndown() {
headingStyle: 'atx', headingStyle: 'atx',
codeBlockStyle: 'fenced', codeBlockStyle: 'fenced',
bulletListMarker: '-', bulletListMarker: '-',
emDelimiter: '*',
})) }))
.then((service) => { .then((service) => {
newsMarkdownTurndown = service newsMarkdownTurndown = service
@@ -1868,7 +1867,7 @@ export default function StudioNewsEditor() {
const { props } = usePage() const { props } = usePage()
const { toasts, push: pushToast, dismiss: dismissToast } = useToast() const { toasts, push: pushToast, dismiss: dismissToast } = useToast()
const article = props.article || {} const article = props.article || {}
const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.typeOptions, props.oldInput || {}), [article, props.defaultAuthor, props.oldInput, props.typeOptions]) const initialFormData = useMemo(() => buildInitialFormData(article, props.defaultAuthor, props.defaultPublishedAt, props.typeOptions, props.oldInput || {}), [article, props.defaultAuthor, props.defaultPublishedAt, props.oldInput, props.typeOptions])
const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData]) const articleSyncKey = useMemo(() => JSON.stringify(initialFormData), [initialFormData])
const [authorResults, setAuthorResults] = useState([]) const [authorResults, setAuthorResults] = useState([])
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '') const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
@@ -2361,8 +2360,8 @@ export default function StudioNewsEditor() {
advancedNews advancedNews
searchEntities={searchEntities} searchEntities={searchEntities}
mediaSupport={{ mediaSupport={{
uploadUrl: props.coverUploadUrl, uploadUrl: props.bodyMediaUploadUrl || props.coverUploadUrl,
deleteUrl: props.coverDeleteUrl, deleteUrl: props.bodyMediaDeleteUrl || props.coverDeleteUrl,
slot: 'body', slot: 'body',
}} }}
/> />

View File

@@ -12,7 +12,7 @@ export default function WorldIndex() {
return ( return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(249,115,22,0.12),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.12),_transparent_32%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8"> <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(249,115,22,0.12),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.12),_transparent_32%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title={props.seo?.title || 'Worlds - Skinbase'} description={props.seo?.description || props.description} image={props.seo?.image} /> <SeoHead seo={props.seo || {}} title="Worlds - Skinbase" description={props.description} />
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
<section className="rounded-[36px] border border-white/10 bg-white/[0.03] p-6 sm:p-8"> <section className="rounded-[36px] border border-white/10 bg-white/[0.03] p-6 sm:p-8">
<div className="max-w-4xl"> <div className="max-w-4xl">

View File

@@ -262,7 +262,7 @@ export default function WorldShow() {
return ( return (
<main ref={rootRef} className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8"> <main ref={rootRef} className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase`} description={props.seo?.description || world?.summary} image={props.seo?.image} /> <SeoHead seo={props.seo || {}} title={`${world?.title || 'World'} - Skinbase`} description={world?.summary} />
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
{previewMode ? ( {previewMode ? (
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50"> <section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50">

View File

@@ -38,6 +38,10 @@
--toolbar-bg-rgb: 15,23,36; --toolbar-bg-rgb: 15,23,36;
} }
@view-transition {
navigation: auto;
}
/* Background / text helpers (used in preview markup) */ /* Background / text helpers (used in preview markup) */
.bg-sb-bg { background-color: var(--sb-bg) !important; } .bg-sb-bg { background-color: var(--sb-bg) !important; }
.bg-sb-top { background-color: var(--sb-top) !important; } .bg-sb-top { background-color: var(--sb-top) !important; }

View File

@@ -327,4 +327,26 @@
@if($needsXEmbeds) @if($needsXEmbeds)
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
@endif @endif
<script>
// Make inline article images open with the same preview used for cover images.
(function () {
function attachStoryImagePreview() {
document.querySelectorAll('.story-prose img').forEach(function (img) {
if (!img) return;
if (!img.hasAttribute('data-news-image-preview')) {
img.setAttribute('data-news-image-preview', '');
img.setAttribute('data-news-image-src', img.getAttribute('src') || img.getAttribute('data-src') || '');
img.setAttribute('data-news-image-alt', img.getAttribute('alt') || img.getAttribute('aria-label') || 'Image preview');
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachStoryImagePreview);
} else {
attachStoryImagePreview();
}
})();
</script>
@endpush @endpush

View File

@@ -10,6 +10,7 @@
<language>en-us</language> <language>en-us</language>
<lastBuildDate>{{ $buildDate }}</lastBuildDate> <lastBuildDate>{{ $buildDate }}</lastBuildDate>
<atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" /> <atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" />
<atom:link href="{{ $canonicalUrl ?? $feedUrl }}" rel="canonical" />
@foreach ($items as $item) @foreach ($items as $item)
<item> <item>
<title><![CDATA[{{ $item['title'] }}]]></title> <title><![CDATA[{{ $item['title'] }}]]></title>

View File

@@ -7,6 +7,7 @@
<language>en-us</language> <language>en-us</language>
<lastBuildDate>{{ $buildDate }}</lastBuildDate> <lastBuildDate>{{ $buildDate }}</lastBuildDate>
<atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" /> <atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" />
<atom:link href="{{ $canonicalUrl ?? $feedUrl }}" rel="canonical" />
@foreach ($artworks as $artwork) @foreach ($artworks as $artwork)
<item> <item>
<title><![CDATA[{{ $artwork->title }}]]></title> <title><![CDATA[{{ $artwork->title }}]]></title>

View File

@@ -1,5 +1,6 @@
@php @php
$newsItems = collect(is_array($items ?? null) ? $items : [])->filter()->take(6)->values(); $newsItems = collect(is_array($items ?? null) ? $items : [])->filter()->take(10)->values();
$carouselId = 'news-carousel-'.uniqid();
@endphp @endphp
@if ($newsItems->isNotEmpty()) @if ($newsItems->isNotEmpty())
@@ -8,24 +9,126 @@
<h2 class="text-xl font-bold text-white">News &amp; Updates</h2> <h2 class="text-xl font-bold text-white">News &amp; Updates</h2>
<a href="/news" class="text-sm text-nova-300 transition hover:text-white">All news</a> <a href="/news" class="text-sm text-nova-300 transition hover:text-white">All news</a>
</div> </div>
<div class="mt-5 relative">
<div class="relative">
<button type="button" class="news-carousel-prev absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-black/70 border border-white/10 p-2 text-white/90 shadow-md hover:scale-105 transition" aria-controls="{{ $carouselId }}" aria-label="Previous news">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" aria-hidden="true"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</button>
<div class="divide-y divide-nova-800 overflow-hidden rounded-[24px] border border-white/10 bg-panel"> <div id="{{ $carouselId }}" class="news-carousel overflow-x-auto snap-x snap-proximity -mx-4 px-4 py-2">
@foreach ($newsItems as $item) <div class="flex gap-4">
<a href="{{ $item['url'] ?? '#' }}" class="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"> @foreach ($newsItems as $item)
<div class="min-w-0"> <article class="snap-start flex-shrink-0 w-[260px] group overflow-hidden rounded-[20px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_12px_30px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
@if (!empty($item['eyebrow'])) <a href="{{ $item['url'] ?? '#' }}" class="block">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{{ $item['eyebrow'] }}</div> <div class="relative aspect-[4/3] overflow-hidden bg-black/20">
@endif @if (!empty($item['cover_url']))
<div class="mt-1 line-clamp-2 text-sm font-medium text-white">{{ $item['title'] ?? 'News item' }}</div> <img
@if (!empty($item['excerpt'])) src="{{ $item['cover_mobile_url'] ?? $item['cover_url'] }}"
<p class="mt-2 line-clamp-2 text-sm leading-6 text-soft">{{ $item['excerpt'] }}</p> @if (!empty($item['cover_srcset'])) srcset="{{ $item['cover_srcset'] }}" sizes="(max-width: 767px) 100vw, 260px" @endif
@endif alt="{{ $item['title'] ?? 'News item' }}"
class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
>
@else
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.98))]"></div>
@endif
<div class="absolute inset-0 bg-gradient-to-t from-[#020611cc] via-transparent to-transparent"></div>
</div>
</a>
<div class="flex h-full flex-col p-3">
<div class="flex flex-wrap items-center gap-2">
@if (!empty($item['type_label']))
<span class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/70">{{ $item['type_label'] }}</span>
@endif
@if (!empty($item['category']['name']))
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-200">{{ $item['category']['name'] }}</span>
@endif
</div>
<h3 class="mt-2 text-lg font-semibold leading-tight text-white/95">
<a href="{{ $item['url'] ?? '#' }}" class="transition hover:text-sky-200">{{ \Illuminate\Support\Str::limit($item['title'] ?? 'News item', 70) }}</a>
</h3>
@if (!empty($item['excerpt']))
<p class="mt-2 flex-1 text-sm leading-6 text-white/55">{{ \Illuminate\Support\Str::limit($item['excerpt'], 110) }}</p>
@endif
<div class="mt-3 flex items-center justify-between gap-3 text-sm text-white/40">
<span class="truncate">{{ $item['author']['name'] ?? 'Skinbase' }}</span>
@if (array_key_exists('views', $item))
<span class="shrink-0 inline-flex items-center gap-1.5">
<i class="fa-regular fa-eye text-[11px]"></i>
{{ number_format((int) ($item['views'] ?? 0)) }}
</span>
@endif
</div>
</div>
</article>
@endforeach
</div> </div>
@if (!empty($item['date'])) </div>
<span class="shrink-0 text-xs text-soft">{{ $item['date'] }}</span>
@endif <button type="button" class="news-carousel-next absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-black/70 border border-white/10 p-2 text-white/90 shadow-md hover:scale-105 transition" aria-controls="{{ $carouselId }}" aria-label="Next news">
</a> <svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" aria-hidden="true"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@endforeach </button>
</div>
</div> </div>
</section> </section>
@endif @endif
@push('scripts')
<script>
(function(){
try {
const carousel = document.getElementById('{{ $carouselId }}');
if (!carousel) return;
const prev = carousel.parentElement.querySelector('.news-carousel-prev');
const next = carousel.parentElement.querySelector('.news-carousel-next');
const card = carousel.querySelector('.snap-start');
if (!card) return;
const gap = 16; // matches gap-4
const scrollByAmount = () => (card.offsetWidth + gap);
prev && prev.addEventListener('click', () => {
carousel.scrollBy({ left: -scrollByAmount(), behavior: 'smooth' });
});
next && next.addEventListener('click', () => {
carousel.scrollBy({ left: scrollByAmount(), behavior: 'smooth' });
});
const updateFades = () => {
const atStart = carousel.scrollLeft <= 8;
const atEnd = carousel.scrollLeft + carousel.clientWidth >= carousel.scrollWidth - 8;
if (prev) {
prev.classList.toggle('opacity-30', atStart);
prev.classList.toggle('pointer-events-none', atStart);
prev.classList.toggle('opacity-100', !atStart);
}
if (next) {
next.classList.toggle('opacity-30', atEnd);
next.classList.toggle('pointer-events-none', atEnd);
next.classList.toggle('opacity-100', !atEnd);
}
};
carousel.addEventListener('scroll', () => {
window.requestAnimationFrame(updateFades);
}, { passive: true });
updateFades();
} catch (e) { /* no-op */ }
})();
</script>
<style>
#{{ $carouselId }} {
scrollbar-width: none;
-ms-overflow-style: none;
}
#{{ $carouselId }}::-webkit-scrollbar {
display: none;
}
</style>
@endpush

View File

@@ -213,6 +213,7 @@ it('renders public group pages and accepts group reports', function () {
->component('Group/GroupShow') ->component('Group/GroupShow')
->where('group.slug', $group->slug) ->where('group.slug', $group->slug)
->where('section', 'overview') ->where('section', 'overview')
->where('seo.canonical', route('groups.show', ['group' => $group]))
->where('featuredArtworks.0.id', $featuredArtwork->id) ->where('featuredArtworks.0.id', $featuredArtwork->id)
->where('featuredCollections.0.id', $featuredCollection->id) ->where('featuredCollections.0.id', $featuredCollection->id)
->where('leadership.0.role', Group::ROLE_OWNER) ->where('leadership.0.role', Group::ROLE_OWNER)
@@ -229,6 +230,27 @@ it('renders public group pages and accepts group reports', function () {
expect(Report::query()->where('target_type', 'group')->where('target_id', $group->id)->count())->toBe(1); expect(Report::query()->where('target_type', 'group')->where('target_id', $group->id)->count())->toBe(1);
}); });
it('adds canonical metadata to public group artwork sections', function () {
$owner = User::factory()->create();
$group = Group::factory()->for($owner, 'owner')->create([
'visibility' => Group::VISIBILITY_PUBLIC,
'status' => Group::LIFECYCLE_ACTIVE,
'slug' => 'pixelart',
'name' => 'Pixelart',
]);
app(GroupMembershipService::class)->ensureOwnerMembership($group);
$this->get(route('groups.section', ['group' => $group, 'section' => 'artworks']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Group/GroupShow')
->where('section', 'artworks')
->where('seo.canonical', route('groups.section', ['group' => $group, 'section' => 'artworks']))
->where('seo.og_url', route('groups.section', ['group' => $group, 'section' => 'artworks']))
);
});
it('lets owners manage releases, contributors, milestones, and publishing through studio endpoints', function () { it('lets owners manage releases, contributors, milestones, and publishing through studio endpoints', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$contributor = User::factory()->create(); $contributor = User::factory()->create();

View File

@@ -44,6 +44,8 @@ it('forbids newsroom studio pages for non moderators', function (): void {
}); });
it('renders newsroom studio pages for moderators', function (): void { it('renders newsroom studio pages for moderators', function (): void {
Carbon::setTestNow(Carbon::parse('2026-06-04 13:45:00'));
$moderator = User::factory()->create([ $moderator = User::factory()->create([
'role' => 'moderator', 'role' => 'moderator',
'username' => 'modnews', 'username' => 'modnews',
@@ -88,7 +90,9 @@ it('renders newsroom studio pages for moderators', function (): void {
->has('statusOptions') ->has('statusOptions')
->has('categoryOptions') ->has('categoryOptions')
->has('tagOptions') ->has('tagOptions')
->where('defaultAuthor.id', $moderator->id)); ->where('defaultAuthor.id', $moderator->id)
->where('defaultAuthor.title', 'Moderator News')
->where('defaultPublishedAt', '2026-06-04T13:45'));
$this->actingAs($moderator) $this->actingAs($moderator)
->get(route('studio.news.categories')) ->get(route('studio.news.categories'))
@@ -104,6 +108,8 @@ it('renders newsroom studio pages for moderators', function (): void {
->assertOk() ->assertOk()
->assertSee('Preview mode') ->assertSee('Preview mode')
->assertSee('Moderated newsroom article'); ->assertSee('Moderated newsroom article');
Carbon::setTestNow();
}); });
it('decodes legacy apostrophe entities in the newsroom editor', function (): void { it('decodes legacy apostrophe entities in the newsroom editor', function (): void {

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\Tag;
use App\Models\User; use App\Models\User;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -33,6 +34,50 @@ it('renders seo tags on auth blade pages', function (): void {
$this->assertStringContainsString('meta name="robots" content="noindex,nofollow"', $html); $this->assertStringContainsString('meta name="robots" content="noindex,nofollow"', $html);
}); });
it('renders canonical tags on public discovery and creator pages', function (string $url, string $routeName): void {
$response = $this->get($url);
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('<link rel="canonical" href="' . route($routeName) . '" />', $html);
})->with([
'discover on this day' => ['/discover/on-this-day', 'discover.on-this-day'],
'discover rising' => ['/discover/rising', 'discover.rising'],
'discover top rated' => ['/discover/top-rated', 'discover.top-rated'],
'discover most downloaded' => ['/discover/most-downloaded', 'discover.most-downloaded'],
'discover fresh' => ['/discover/fresh', 'discover.fresh'],
'discover trending' => ['/discover/trending', 'discover.trending'],
'top creators' => ['/creators/top', 'creators.top'],
]);
it('renders a canonical tag on the register page', function (): void {
$response = $this->get('/register');
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('<link rel="canonical" href="' . route('register') . '" />', $html);
});
it('renders canonical atom links in rss feed headers', function (): void {
Tag::factory()->create([
'name' => 'Abstract Composition',
'slug' => 'abstract-composition',
'is_active' => true,
]);
$this->get('/rss')
->assertOk()
->assertSee('<atom:link href="' . route('rss.global') . '" rel="canonical" />', false);
$this->get('/rss/tag/abstract-composition')
->assertOk()
->assertSee('<atom:link href="' . route('rss.tag', ['slug' => 'abstract-composition']) . '" rel="canonical" />', false);
});
it('renders seo tags on upload and studio pages', function (): void { it('renders seo tags on upload and studio pages', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();

View File

@@ -98,7 +98,8 @@ it('renders public worlds index and detail pages', function (): void {
->assertInertia(fn (AssertableInertia $page) => $page ->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow') ->component('World/WorldShow')
->where('world.title', 'Summer Slam 2026') ->where('world.title', 'Summer Slam 2026')
->where('world.slug', 'summer-slam-2026')); ->where('world.slug', 'summer-slam-2026')
->where('seo.canonical', route('worlds.show', ['world' => $world->slug])));
}); });
it('returns a relative latest world navigation link regardless of request host', function (): void { it('returns a relative latest world navigation link regardless of request host', function (): void {