fixed sanitazer and academy
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 ?? [])),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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": [],
|
||||||
|
|||||||
2244
bootstrap/ssr/ssr.js
2244
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
639
resources/js/Pages/Admin/Academy/ChallengeEditor.jsx
Normal file
639
resources/js/Pages/Admin/Academy/ChallengeEditor.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & Updates</h2>
|
<h2 class="text-xl font-bold text-white">News & 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
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user