Refine SEO, uploads, and deploy handling

This commit is contained in:
2026-05-02 10:48:08 +02:00
parent b6be6ed2ac
commit a9dfa6ea11
97 changed files with 373 additions and 327 deletions

View File

@@ -41,7 +41,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions # Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true

View File

@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
public function __invoke(int $id, Request $request): JsonResponse public function __invoke(int $id, Request $request): JsonResponse
{ {
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id); $artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork); $this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit)); return response()->json([
'tags' => [],
'vision_enabled' => false,
'reason' => 'disabled',
]);
} }
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void private function authorizeOrNotFound(mixed $user, Artwork $artwork): void

View File

@@ -108,7 +108,7 @@ class CollectionInsightsController extends Controller
'bulkActions' => route('settings.collections.bulk-actions'), 'bulkActions' => route('settings.collections.bulk-actions'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova', 'title' => 'Collections Dashboard — Skinbase',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.', 'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'), 'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',
@@ -127,7 +127,7 @@ class CollectionInsightsController extends Controller
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]), 'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'), 'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [ 'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title), 'title' => sprintf('%s Analytics — Skinbase', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title), 'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]), 'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',
@@ -150,7 +150,7 @@ class CollectionInsightsController extends Controller
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]), 'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']), 'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [ 'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title), 'title' => sprintf('%s History — Skinbase', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title), 'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]), 'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',

View File

@@ -92,7 +92,7 @@ class CollectionProgrammingController extends Controller
'surfaces' => route('settings.collections.surfaces.index'), 'surfaces' => route('settings.collections.surfaces.index'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collection Programming — Skinbase Nova', 'title' => 'Collection Programming — Skinbase',
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.', 'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
'canonical' => route('staff.collections.programming'), 'canonical' => route('staff.collections.programming'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',

View File

@@ -66,7 +66,7 @@ class CollectionSurfaceController extends Controller
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'), 'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
], ],
'seo' => [ 'seo' => [
'title' => 'Collection Surfaces - Skinbase Nova', 'title' => 'Collection Surfaces - Skinbase',
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.', 'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
'canonical' => route('settings.collections.surfaces.index'), 'canonical' => route('settings.collections.surfaces.index'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',

View File

@@ -43,7 +43,7 @@ class FeaturedArtworkAdminController extends Controller
'forceHeroEnabled' => $this->hasForceHeroColumn(), 'forceHeroEnabled' => $this->hasForceHeroColumn(),
], ],
'seo' => [ 'seo' => [
'title' => 'Featured Artworks — Skinbase Nova', 'title' => 'Featured Artworks — Skinbase',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.', 'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route($routePrefix . 'main'), 'canonical' => route($routePrefix . 'main'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',

View File

@@ -40,7 +40,7 @@ final class StudioWorldController extends Controller
return Inertia::render('Studio/StudioWorldsIndex', [ return Inertia::render('Studio/StudioWorldsIndex', [
'title' => 'Worlds', 'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.', 'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])), 'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
'analytics' => $this->analytics->portfolioReport(), 'analytics' => $this->analytics->portfolioReport(),
'statusOptions' => [ 'statusOptions' => [
@@ -435,7 +435,7 @@ final class StudioWorldController extends Controller
$payload = $this->worlds->publicShowPayload($world, $request->user(), true); $payload = $this->worlds->publicShowPayload($world, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'), $world->seo_title ?: ($world->title . ' — Skinbase Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'), $world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
route('studio.worlds.preview', ['world' => $world]), route('studio.worlds.preview', ['world' => $world]),
$world->ogImageUrl(), $world->ogImageUrl(),

View File

@@ -116,9 +116,9 @@ class ProfileCollectionController extends Controller
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured $collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()) ? sprintf('Featured: %s by %s — Skinbase', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()), : sprintf('%s by %s — Skinbase', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()), $collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'], $collectionPayload['public_url'],
$collectionPayload['cover_image'], $collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC, $collection->visibility === Collection::VISIBILITY_PUBLIC,
@@ -202,8 +202,8 @@ class ProfileCollectionController extends Controller
$seriesDescription = $seriesMeta['description']; $seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey), sprintf('Series: %s — Skinbase', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey), sprintf('Explore the %s collection series on Skinbase.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey]) route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray(); )->toArray();

View File

@@ -155,8 +155,8 @@ class SavedCollectionController extends Controller
'libraryUrl' => route('me.saved.collections'), 'libraryUrl' => route('me.saved.collections'),
'browseUrl' => route('collections.featured'), 'browseUrl' => route('collections.featured'),
'seo' => [ 'seo' => [
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova', 'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase', $activeList->title) : 'Saved Collections — Skinbase',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.', 'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase.', $activeList->title) : 'Your saved collections on Skinbase.',
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'), 'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
'robots' => 'noindex,follow', 'robots' => 'noindex,follow',
], ],

View File

@@ -18,7 +18,7 @@ final class AccountHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Account Settings Help — Skinbase', 'Account Settings Help — Skinbase',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.', 'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase.',
$canonical, $canonical,
) )
->toArray(); ->toArray();

View File

@@ -18,7 +18,7 @@ final class AuthHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Signup and Login Help — Skinbase', 'Signup and Login Help — Skinbase',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.', 'Learn how signup, login, password recovery, verification, and account access work on Skinbase, with clear guidance for common access problems and practical next steps.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class AuthHelpPageController extends Controller
return Inertia::render('Help/AuthHelpPage', [ return Inertia::render('Help/AuthHelpPage', [
'title' => 'Signup & Login Help', 'title' => 'Signup & Login Help',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.', 'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),

View File

@@ -184,7 +184,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
(object) ['name' => 'Explore', 'url' => '/browse'], (object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug], (object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]), ]),
'page_title' => $contentType->name . ' Skinbase Nova', 'page_title' => $contentType->name . ' Skinbase',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],
@@ -264,7 +264,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $category->name, 'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs, 'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name . ' Skinbase Nova', 'page_title' => $category->name . ' Skinbase',
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'], 'page_canonical' => $seo['canonical'],

View File

@@ -18,7 +18,7 @@ final class CardsHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Cards Help — Skinbase', 'Cards Help — Skinbase',
'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.', 'Learn what Cards are on Skinbase, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class CardsHelpPageController extends Controller
return Inertia::render('Help/CardsHelpPage', [ return Inertia::render('Help/CardsHelpPage', [
'title' => 'Cards Help', 'title' => 'Cards Help',
'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.', 'description' => 'Understand Cards as a distinct creative format on Skinbase, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),

View File

@@ -52,9 +52,9 @@ class CollectionDiscoveryController extends Controller
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18)); $results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
'Search Collections — Skinbase Nova', 'Search Collections — Skinbase',
filled($filters['q'] ?? null) filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q']) ? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.', : 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
$request->fullUrl(), $request->fullUrl(),
null, null,
@@ -65,7 +65,7 @@ class CollectionDiscoveryController extends Controller
'eyebrow' => 'Search', 'eyebrow' => 'Search',
'title' => 'Search collections', 'title' => 'Search collections',
'description' => filled($filters['q'] ?? null) 'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q']) ? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.', : 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => $seo, 'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()), 'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
@@ -100,7 +100,7 @@ class CollectionDiscoveryController extends Controller
viewer: $request->user(), viewer: $request->user(),
eyebrow: 'Discovery', eyebrow: 'Discovery',
title: 'Featured collections', title: 'Featured collections',
description: 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.', description: 'A rotating set of standout galleries from across Skinbase. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)), collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)),
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6), communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6), editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
@@ -204,7 +204,7 @@ class CollectionDiscoveryController extends Controller
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404); abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $program['label']), sprintf('%s — Skinbase', $program['label']),
$program['description'], $program['description'],
route('collections.program.show', ['programKey' => $program['key']]), route('collections.program.show', ['programKey' => $program['key']]),
)->toArray(); )->toArray();
@@ -239,7 +239,7 @@ class CollectionDiscoveryController extends Controller
$campaign = null, $campaign = null,
) { ) {
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $title), sprintf('%s — Skinbase', $title),
$description, $description,
url()->current(), url()->current(),
)->toArray(); )->toArray();

View File

@@ -18,7 +18,7 @@ final class GroupFaqPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Groups FAQ — Skinbase', 'Groups FAQ — Skinbase',
'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.', 'Fast answers to the most common Groups questions on Skinbase, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class GroupFaqPageController extends Controller
return Inertia::render('Group/GroupFaqPage', [ return Inertia::render('Group/GroupFaqPage', [
'title' => 'Groups FAQ', 'title' => 'Groups FAQ',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.', 'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'groups_directory' => route('groups.index'), 'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Groups Guide, Help, and Best Practices — Skinbase', 'Groups Guide, Help, and Best Practices — Skinbase',
'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.', 'Learn how Groups work on Skinbase, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class GroupHelpPageController extends Controller
return Inertia::render('Group/GroupHelpPage', [ return Inertia::render('Group/GroupHelpPage', [
'title' => 'Groups Help & Guide', 'title' => 'Groups Help & Guide',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.', 'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'groups_directory' => route('groups.index'), 'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupQuickstartPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Groups Quickstart — Skinbase', 'Groups Quickstart — Skinbase',
'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.', 'A fast, creator-friendly Groups quickstart for Skinbase. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
$canonical, $canonical,
) )
->toArray(); ->toArray();

View File

@@ -18,7 +18,7 @@ final class HelpCenterPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Help Center — Skinbase', 'Help Center — Skinbase',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.', 'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase, including Groups, Studio, Upload, Cards, Profile, and account access.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class HelpCenterPageController extends Controller
return Inertia::render('Help/HelpCenterPage', [ return Inertia::render('Help/HelpCenterPage', [
'title' => 'Help Center', 'title' => 'Help Center',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.', 'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase in one structured help hub.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'studio_help' => route('help.studio'), 'studio_help' => route('help.studio'),

View File

@@ -60,12 +60,12 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Nova Cards - Skinbase Nova', 'title' => 'Cards - Skinbase',
'description' => 'Browse featured, trending, and latest Nova Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase Nova community.', 'description' => 'Browse featured, trending, and latest Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase community.',
'canonical' => route('cards.index'), 'canonical' => route('cards.index'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Nova Cards', 'heading' => 'Cards',
'subheading' => (string) config('nova_cards.brand.subtitle'), 'subheading' => (string) config('nova_cards.brand.subtitle'),
'cards' => $this->presenter->cards($latest->items()), 'cards' => $this->presenter->cards($latest->items()),
'pagination' => $latest, 'pagination' => $latest,
@@ -90,13 +90,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => $category->name . ' Cards - Skinbase Nova', 'title' => $category->name . ' Cards - Skinbase',
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Nova Cards on Skinbase Nova.'), 'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Cards on Skinbase.'),
'canonical' => route('cards.category', ['categorySlug' => $category->slug]), 'canonical' => route('cards.category', ['categorySlug' => $category->slug]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => $category->name, 'heading' => $category->name,
'subheading' => $category->description ?: 'Explore this Nova Cards category.', 'subheading' => $category->description ?: 'Explore this Cards category.',
'cards' => $this->presenter->cards($cards->items()), 'cards' => $this->presenter->cards($cards->items()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -119,8 +119,8 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Popular Cards - Skinbase Nova', 'title' => 'Popular Cards - Skinbase',
'description' => 'Browse the most liked, saved, and viewed Nova Cards on Skinbase Nova.', 'description' => 'Browse the most liked, saved, and viewed Cards on Skinbase.',
'canonical' => route('cards.popular'), 'canonical' => route('cards.popular'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -153,13 +153,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Rising Cards - Skinbase Nova', 'title' => 'Rising Cards - Skinbase',
'description' => 'Discover Nova Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.', 'description' => 'Discover Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
'canonical' => route('cards.rising'), 'canonical' => route('cards.rising'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Rising', 'heading' => 'Rising',
'subheading' => 'Fresh Nova Cards gaining momentum right now.', 'subheading' => 'Fresh Cards gaining momentum right now.',
'cards' => $this->presenter->cards($paginated->items(), false, $request->user()), 'cards' => $this->presenter->cards($paginated->items(), false, $request->user()),
'pagination' => $paginated, 'pagination' => $paginated,
'featuredCards' => [], 'featuredCards' => [],
@@ -182,13 +182,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Remixed Cards - Skinbase Nova', 'title' => 'Remixed Cards - Skinbase',
'description' => 'Discover Nova Cards remixed from community originals with attribution and lineage.', 'description' => 'Discover Cards remixed from community originals with attribution and lineage.',
'canonical' => route('cards.remixed'), 'canonical' => route('cards.remixed'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Remixed cards', 'heading' => 'Remixed cards',
'subheading' => 'Community reinterpretations linked back to their original Nova Cards.', 'subheading' => 'Community reinterpretations linked back to their original Cards.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -214,8 +214,8 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Best Remixes - Skinbase Nova', 'title' => 'Best Remixes - Skinbase',
'description' => 'Browse standout Nova Card remixes ranked by remix traction, saves, and likes.', 'description' => 'Browse standout Card remixes ranked by remix traction, saves, and likes.',
'canonical' => route('cards.remix-highlights'), 'canonical' => route('cards.remix-highlights'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -295,13 +295,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Editorial Picks - Nova Cards - Skinbase Nova', 'title' => 'Editorial Picks - Cards - Skinbase',
'description' => 'Browse editorial Nova Cards picks, featured collections, and highlighted challenges.', 'description' => 'Browse editorial Cards picks, featured collections, and highlighted challenges.',
'canonical' => route('cards.editorial'), 'canonical' => route('cards.editorial'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Editorial picks', 'heading' => 'Editorial picks',
'subheading' => 'Curated Nova Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.', 'subheading' => 'Curated Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -329,13 +329,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => 'Seasonal Cards - Nova Cards - Skinbase Nova', 'title' => 'Seasonal Cards - Cards - Skinbase',
'description' => 'Browse seasonal and event-aware Nova Cards grouped by recurring moods, holidays, and time-of-year themes.', 'description' => 'Browse seasonal and event-aware Cards grouped by recurring moods, holidays, and time-of-year themes.',
'canonical' => route('cards.seasonal'), 'canonical' => route('cards.seasonal'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Seasonal cards', 'heading' => 'Seasonal cards',
'subheading' => 'Discover Nova Cards grouped by recurring seasonal and campaign-style themes.', 'subheading' => 'Discover Cards grouped by recurring seasonal and campaign-style themes.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -363,13 +363,13 @@ class NovaCardsController extends Controller
return view('cards.challenges', [ return view('cards.challenges', [
'meta' => [ 'meta' => [
'title' => 'Card Challenges - Skinbase Nova', 'title' => 'Card Challenges - Skinbase',
'description' => 'Browse active and completed Nova Cards challenges, prompts, and winners.', 'description' => 'Browse active and completed Cards challenges, prompts, and winners.',
'canonical' => route('cards.challenges'), 'canonical' => route('cards.challenges'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Card challenges', 'heading' => 'Card challenges',
'subheading' => 'Official prompts and community challenge runs for Nova Cards creators.', 'subheading' => 'Official prompts and community challenge runs for Cards creators.',
'challenges' => $challenges, 'challenges' => $challenges,
]); ]);
} }
@@ -388,8 +388,8 @@ class NovaCardsController extends Controller
return view('cards.challenges', [ return view('cards.challenges', [
'meta' => [ 'meta' => [
'title' => $challenge->title . ' - Skinbase Nova', 'title' => $challenge->title . ' - Skinbase',
'description' => $challenge->description ?: 'Browse entries for this Nova Cards challenge.', 'description' => $challenge->description ?: 'Browse entries for this Cards challenge.',
'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]), 'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -410,8 +410,8 @@ class NovaCardsController extends Controller
{ {
return view('cards.resources', [ return view('cards.resources', [
'meta' => [ 'meta' => [
'title' => 'Template Packs - Skinbase Nova', 'title' => 'Template Packs - Skinbase',
'description' => 'Browse official Nova Cards template packs and starting points.', 'description' => 'Browse official Cards template packs and starting points.',
'canonical' => route('cards.templates'), 'canonical' => route('cards.templates'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -427,13 +427,13 @@ class NovaCardsController extends Controller
{ {
return view('cards.resources', [ return view('cards.resources', [
'meta' => [ 'meta' => [
'title' => 'Asset Packs - Skinbase Nova', 'title' => 'Asset Packs - Skinbase',
'description' => 'Browse official Nova Cards asset packs for decorative and editorial layouts.', 'description' => 'Browse official Cards asset packs for decorative and editorial layouts.',
'canonical' => route('cards.assets'), 'canonical' => route('cards.assets'),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => 'Asset packs', 'heading' => 'Asset packs',
'subheading' => 'Official decorative and editorial pack sets for the Nova Cards v2 editor.', 'subheading' => 'Official decorative and editorial pack sets for the Cards v2 editor.',
'packs' => collect($this->presenter->options()['asset_packs'] ?? []), 'packs' => collect($this->presenter->options()['asset_packs'] ?? []),
'templates' => collect(), 'templates' => collect(),
'resourceType' => 'asset', 'resourceType' => 'asset',
@@ -447,8 +447,8 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => '#' . $tag->name . ' Cards - Skinbase Nova', 'title' => '#' . $tag->name . ' Cards - Skinbase',
'description' => 'Browse Nova Cards tagged with #' . $tag->name . ' on Skinbase Nova.', 'description' => 'Browse Cards tagged with #' . $tag->name . ' on Skinbase.',
'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]), 'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -480,13 +480,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => $mood['label'] . ' Mood Cards - Skinbase Nova', 'title' => $mood['label'] . ' Mood Cards - Skinbase',
'description' => 'Browse Nova Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase Nova.', 'description' => 'Browse Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase.',
'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]), 'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => $mood['label'], 'heading' => $mood['label'],
'subheading' => 'Discover Nova Cards grouped by a curated mood family using durable tag mappings.', 'subheading' => 'Discover Cards grouped by a curated mood family using durable tag mappings.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -514,13 +514,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => $style['label'] . ' Style Cards - Skinbase Nova', 'title' => $style['label'] . ' Style Cards - Skinbase',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase Nova.', 'description' => 'Browse Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase.',
'canonical' => route('cards.style', ['styleSlug' => $style['key']]), 'canonical' => route('cards.style', ['styleSlug' => $style['key']]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => $style['label'], 'heading' => $style['label'],
'subheading' => 'Discover Nova Cards grouped by a shared visual style family.', 'subheading' => 'Discover Cards grouped by a shared visual style family.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -548,13 +548,13 @@ class NovaCardsController extends Controller
return view('cards.index', [ return view('cards.index', [
'meta' => [ 'meta' => [
'title' => $palette['label'] . ' Palette Cards - Skinbase Nova', 'title' => $palette['label'] . ' Palette Cards - Skinbase',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase Nova.', 'description' => 'Browse Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase.',
'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]), 'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => $palette['label'], 'heading' => $palette['label'],
'subheading' => 'Discover Nova Cards grouped by shared palette families and color direction.', 'subheading' => 'Discover Cards grouped by shared palette families and color direction.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards, 'pagination' => $cards,
'featuredCards' => [], 'featuredCards' => [],
@@ -580,8 +580,8 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [ return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [ 'meta' => [
'title' => '@' . $user->username . ' Cards - Skinbase Nova', 'title' => '@' . $user->username . ' Cards - Skinbase',
'description' => 'Browse Nova Cards created by @' . $user->username . ' on Skinbase Nova.', 'description' => 'Browse Cards created by @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]), 'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -602,13 +602,13 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [ return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [ 'meta' => [
'title' => '@' . $user->username . ' Portfolio - Skinbase Nova', 'title' => '@' . $user->username . ' Portfolio - Skinbase',
'description' => 'Browse the dedicated Nova Cards portfolio page for @' . $user->username . ' on Skinbase Nova.', 'description' => 'Browse the dedicated Cards portfolio page for @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]), 'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
'heading' => '@' . $user->username . ' Portfolio', 'heading' => '@' . $user->username . ' Portfolio',
'subheading' => 'A dedicated Nova Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.', 'subheading' => 'A dedicated Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
'context' => 'creator-portfolio', 'context' => 'creator-portfolio',
])); ]));
} }
@@ -695,8 +695,8 @@ class NovaCardsController extends Controller
return view('cards.collection', [ return view('cards.collection', [
'meta' => [ 'meta' => [
'title' => $collection->name . ' - Nova Cards Collection - Skinbase Nova', 'title' => $collection->name . ' - Cards Collection - Skinbase',
'description' => $collection->description ?: 'Browse this curated Nova Cards collection.', 'description' => $collection->description ?: 'Browse this curated Cards collection.',
'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]), 'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]),
'robots' => 'index,follow', 'robots' => 'index,follow',
], ],
@@ -721,7 +721,7 @@ class NovaCardsController extends Controller
return view('cards.lineage', [ return view('cards.lineage', [
'meta' => [ 'meta' => [
'title' => $card->title . ' Lineage - Nova Cards - Skinbase Nova', 'title' => $card->title . ' Lineage - Cards - Skinbase',
'description' => 'Browse the remix lineage and related variants for this Nova Card.', 'description' => 'Browse the remix lineage and related variants for this Nova Card.',
'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]), 'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => 'index,follow', 'robots' => 'index,follow',
@@ -767,7 +767,7 @@ class NovaCardsController extends Controller
return view('cards.show', [ return view('cards.show', [
'card' => $this->presenter->card($card, true, $request->user()), 'card' => $this->presenter->card($card, true, $request->user()),
'meta' => [ 'meta' => [
'title' => $card->title . ' - Nova Cards - Skinbase Nova', 'title' => $card->title . ' - Cards - Skinbase',
'description' => $card->description ?: $card->quote_text, 'description' => $card->description ?: $card->quote_text,
'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]), 'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow', 'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow',

View File

@@ -18,7 +18,7 @@ final class ProfileHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Profile Help — Skinbase', 'Profile Help — Skinbase',
'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.', 'Learn how profiles work on Skinbase, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
$canonical, $canonical,
) )
->toArray(); ->toArray();

View File

@@ -18,7 +18,7 @@ final class StudioHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Studio Help — Skinbase', 'Studio Help — Skinbase',
'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.', 'Learn how Studio works on Skinbase, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class StudioHelpPageController extends Controller
return Inertia::render('Help/StudioHelpPage', [ return Inertia::render('Help/StudioHelpPage', [
'title' => 'Studio Help', 'title' => 'Studio Help',
'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.', 'description' => 'Understand Studio as the creative control center of Skinbase, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),

View File

@@ -18,7 +18,7 @@ final class TroubleshootingHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Troubleshooting Help — Skinbase', 'Troubleshooting Help — Skinbase',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.', 'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase.',
$canonical, $canonical,
) )
->toArray(); ->toArray();

View File

@@ -18,7 +18,7 @@ final class UploadHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Upload Help — Skinbase', 'Upload Help — Skinbase',
'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.', 'Learn how uploading works on Skinbase, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class UploadHelpPageController extends Controller
return Inertia::render('Help/UploadHelpPage', [ return Inertia::render('Help/UploadHelpPage', [
'title' => 'Upload Help', 'title' => 'Upload Help',
'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.', 'description' => 'Understand the full upload workflow on Skinbase, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),

View File

@@ -22,7 +22,7 @@ final class WorldController extends Controller
{ {
$payload = $this->worlds->publicIndexPayload($request->user()); $payload = $this->worlds->publicIndexPayload($request->user());
$seo = app(SeoFactory::class)->collectionListing( $seo = app(SeoFactory::class)->collectionListing(
'Worlds — Skinbase Nova', 'Worlds — Skinbase',
$payload['description'], $payload['description'],
route('worlds.index'), route('worlds.index'),
)->toArray(); )->toArray();
@@ -45,8 +45,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user()); $payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'), $resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'), $resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld), $this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(), $resolvedWorld->ogImageUrl(),
)->toArray(); )->toArray();
@@ -69,8 +69,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user()); $payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage( $seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'), $resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'), $resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld), $this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(), $resolvedWorld->ogImageUrl(),
)->toArray(); )->toArray();

View File

@@ -18,7 +18,7 @@ final class WorldsHelpPageController extends Controller
$seo = app(SeoFactory::class) $seo = app(SeoFactory::class)
->collectionPage( ->collectionPage(
'Worlds Help — Skinbase', 'Worlds Help — Skinbase',
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.', 'Learn how Worlds work on Skinbase, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
$canonical, $canonical,
) )
->toArray(); ->toArray();
@@ -27,7 +27,7 @@ final class WorldsHelpPageController extends Controller
return Inertia::render('Help/WorldsHelpPage', [ return Inertia::render('Help/WorldsHelpPage', [
'title' => 'Worlds Help', 'title' => 'Worlds Help',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.', 'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase.',
'seo' => $seo, 'seo' => $seo,
'links' => [ 'links' => [
'help_home' => route('help'), 'help_home' => route('help'),

View File

@@ -48,7 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
public function handle( public function handle(
ArtworkEmbeddingClient $client, ArtworkEmbeddingClient $client,
ArtworkVisionImageUrl $imageUrlBuilder, ArtworkVisionImageUrl $imageUrlBuilder,
VectorService|ArtworkVectorIndexService $vectors, ArtworkVectorIndexService $vectors,
): void ): void
{ {
if (! (bool) config('recommendations.embedding.enabled', true)) { if (! (bool) config('recommendations.embedding.enabled', true)) {
@@ -128,7 +128,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
} }
private function upsertVectorIndex( private function upsertVectorIndex(
VectorService|ArtworkVectorIndexService $vectors, ArtworkVectorIndexService $vectors,
Artwork $artwork Artwork $artwork
): void ): void
{ {

View File

@@ -25,7 +25,7 @@ final class AiBiographyPromptBuilder
private const MIN_WORDS = 30; private const MIN_WORDS = 30;
private const SYSTEM_PROMPT = <<<'PROMPT' private const SYSTEM_PROMPT = <<<'PROMPT'
You are a concise writing assistant for Skinbase Nova, a digital art platform. You are a concise writing assistant for Skinbase, a digital art platform.
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone. Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
@@ -44,7 +44,7 @@ Rules:
PROMPT; PROMPT;
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT' private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform. You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, safe creator biography using only the facts provided. Be conservative. Write a short, safe creator biography using only the facts provided. Be conservative.
@@ -59,7 +59,7 @@ Rules:
PROMPT; PROMPT;
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT' private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform. You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, modest creator introduction using only the facts provided. Write a short, modest creator introduction using only the facts provided.
@@ -75,7 +75,7 @@ Rules:
PROMPT; PROMPT;
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT' private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform. You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, modest creator introduction using only the facts provided. Be conservative and precise. Write a short, modest creator introduction using only the facts provided. Be conservative and precise.

View File

@@ -75,7 +75,7 @@ class CollectionAiCurationService
); );
$seo = sprintf( $seo = sprintf(
'%s on Skinbase Nova: %d curated artworks%s.', '%s on Skinbase: %d curated artworks%s.',
$this->draftString($collection, $draft, 'title') ?: $collection->title, $this->draftString($collection, $draft, 'title') ?: $collection->title,
$context['artworks_count'], $context['artworks_count'],
$context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : '' $context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : ''

View File

@@ -238,7 +238,7 @@ final class ArtworkSquareThumbnailBackfillService
'timeout' => 30, 'timeout' => 30,
'ignore_errors' => true, 'ignore_errors' => true,
'header' => implode("\r\n", [ 'header' => implode("\r\n", [
'User-Agent: Skinbase Nova square-thumb backfill', 'User-Agent: Skinbase square-thumb backfill',
'Accept: image/*,*/*;q=0.8', 'Accept: image/*,*/*;q=0.8',
'Accept-Encoding: identity', 'Accept-Encoding: identity',
'Connection: close', 'Connection: close',

View File

@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
/** /**
* ArtworkRankingService Skinbase Nova Ranking Engine V2 * ArtworkRankingService Skinbase Ranking Engine V2
* *
* Intelligent scoring system combining: * Intelligent scoring system combining:
* 1. Base engagement (views, downloads, favourites, comments, shares) * 1. Base engagement (views, downloads, favourites, comments, shares)

View File

@@ -11,7 +11,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
* RankingService Skinbase Nova rank_v2 * RankingService Skinbase rank_v2
* *
* Responsibilities: * Responsibilities:
* 1. Score computation turn raw artwork signals into three float scores. * 1. Score computation turn raw artwork signals into three float scores.

View File

@@ -33,7 +33,7 @@ final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
route('news.show', ['slug' => $article->slug]), route('news.show', ['slug' => $article->slug]),
trim((string) $article->title), trim((string) $article->title),
$article->published_at, $article->published_at,
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'), (string) \config('sitemaps.news.google_publication_name', 'Skinbase'),
(string) \config('sitemaps.news.google_language', 'en'), (string) \config('sitemaps.news.google_language', 'en'),
); );
}) })

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Ranking system configuration Skinbase Nova rank_v1 * Ranking system configuration Skinbase rank_v1
* *
* All weights, half-lives, and thresholds are tunable here. * All weights, half-lives, and thresholds are tunable here.
* Increment model_version when changing weights so caches expire gracefully. * Increment model_version when changing weights so caches expire gracefully.

View File

@@ -75,7 +75,7 @@ return [
'news' => [ 'news' => [
'google_variant_enabled' => (bool) env('SITEMAPS_NEWS_GOOGLE_VARIANT', true), 'google_variant_enabled' => (bool) env('SITEMAPS_NEWS_GOOGLE_VARIANT', true),
'google_variant_name' => 'news-google', 'google_variant_name' => 'news-google',
'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase Nova')), 'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase')),
'google_language' => env('SITEMAPS_NEWS_GOOGLE_LANGUAGE', env('APP_LOCALE', 'en')), 'google_language' => env('SITEMAPS_NEWS_GOOGLE_LANGUAGE', env('APP_LOCALE', 'en')),
'google_lookback_hours' => (int) env('SITEMAPS_NEWS_GOOGLE_LOOKBACK_HOURS', 48), 'google_lookback_hours' => (int) env('SITEMAPS_NEWS_GOOGLE_LOOKBACK_HOURS', 48),
'google_max_items' => (int) env('SITEMAPS_NEWS_GOOGLE_MAX_ITEMS', 1000), 'google_max_items' => (int) env('SITEMAPS_NEWS_GOOGLE_MAX_ITEMS', 1000),

View File

@@ -19,16 +19,16 @@ final class HomepageAnnouncementLaunchSeeder extends Seeder
[ [
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
'type' => HomepageAnnouncement::TYPE_LAUNCH, 'type' => HomepageAnnouncement::TYPE_LAUNCH,
'title' => 'Skinbase Nova is live.', 'title' => 'Skinbase is live.',
], ],
[ [
'subtitle' => 'A new chapter for the Skinbase creative community.', 'subtitle' => 'A new chapter for the Skinbase creative community.',
'badge_text' => 'Launch Day · 1 May 2026', 'badge_text' => 'Launch Day · 1 May 2026',
'content_html' => implode("\n", [ 'content_html' => implode("\n", [
'<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>', '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
'<p>Skinbase Nova is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>', '<p>Skinbase is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
'<p>We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.</p>', '<p>We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.</p>',
'<p>Welcome to <strong>Skinbase Nova</strong>.</p>', '<p>Welcome to <strong>Skinbase</strong>.</p>',
]), ]),
'status' => HomepageAnnouncement::STATUS_PUBLISHED, 'status' => HomepageAnnouncement::STATUS_PUBLISHED,
'is_active' => true, 'is_active' => true,

View File

@@ -54,11 +54,11 @@ final class NewsLaunchSeeder extends Seeder
$articles = [ $articles = [
[ [
'slug' => 'welcome-to-skinbase-nova', 'slug' => 'welcome-to-skinbase-nova',
'title' => 'Welcome to Skinbase Nova', 'title' => 'Welcome to Skinbase',
'type' => NewsArticle::TYPE_PLATFORM_UPDATE, 'type' => NewsArticle::TYPE_PLATFORM_UPDATE,
'category' => $categories['platform'], 'category' => $categories['platform'],
'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.', 'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.',
'content' => "# Welcome to Skinbase Nova\n\nSkinbase Nova brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.", 'content' => "# Welcome to Skinbase\n\nSkinbase brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
'tags' => [$tags['nova'], $tags['platform-update']], 'tags' => [$tags['nova'], $tags['platform-update']],
'days_ago' => 10, 'days_ago' => 10,
'featured' => true, 'featured' => true,

View File

@@ -32,7 +32,7 @@ class NovaCardDemoSeeder extends Seeder
['email' => (string) Arr::get($userConfig, 'email', 'nova-cards-demo@skinbase.test')], ['email' => (string) Arr::get($userConfig, 'email', 'nova-cards-demo@skinbase.test')],
[ [
'username' => (string) Arr::get($userConfig, 'username', 'nova.cards'), 'username' => (string) Arr::get($userConfig, 'username', 'nova.cards'),
'name' => (string) Arr::get($userConfig, 'name', 'Nova Cards'), 'name' => (string) Arr::get($userConfig, 'name', 'Cards'),
'password' => (string) Arr::get($userConfig, 'password', 'password'), 'password' => (string) Arr::get($userConfig, 'password', 'password'),
'role' => 'user', 'role' => 'user',
] ]
@@ -43,9 +43,9 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'official-spark', 'slug' => 'official-spark',
'title' => 'Official Spark', 'title' => 'Official Spark',
'quote_text' => 'Small moments of focus turn into visible momentum.', 'quote_text' => 'Small moments of focus turn into visible momentum.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Launch Collection', 'quote_source' => 'Launch Collection',
'description' => 'An official Nova Cards demo card for featured browse surfaces.', 'description' => 'An official Cards demo card for featured browse surfaces.',
'category_slug' => 'motivation', 'category_slug' => 'motivation',
'template_slug' => 'neon-nova', 'template_slug' => 'neon-nova',
'format' => NovaCard::FORMAT_SQUARE, 'format' => NovaCard::FORMAT_SQUARE,
@@ -61,9 +61,9 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'soft-breath', 'slug' => 'soft-breath',
'title' => 'Soft Breath', 'title' => 'Soft Breath',
'quote_text' => 'Rest is not a pause from growth. It is part of it.', 'quote_text' => 'Rest is not a pause from growth. It is part of it.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Healing Notes', 'quote_source' => 'Healing Notes',
'description' => 'A calm demo card showing the softer side of Nova Cards.', 'description' => 'A calm demo card showing the softer side of Cards.',
'category_slug' => 'healing', 'category_slug' => 'healing',
'template_slug' => 'soft-pastel', 'template_slug' => 'soft-pastel',
'format' => NovaCard::FORMAT_PORTRAIT, 'format' => NovaCard::FORMAT_PORTRAIT,
@@ -79,7 +79,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'night-echo', 'slug' => 'night-echo',
'title' => 'Night Echo', 'title' => 'Night Echo',
'quote_text' => 'Not every quiet room is empty. Some are full of answers.', 'quote_text' => 'Not every quiet room is empty. Some are full of answers.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Dark Mood Study', 'quote_source' => 'Dark Mood Study',
'description' => 'A darker official demo card for mood-oriented discovery blocks.', 'description' => 'A darker official demo card for mood-oriented discovery blocks.',
'category_slug' => 'dark-mood', 'category_slug' => 'dark-mood',
@@ -97,7 +97,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'editorial-glow', 'slug' => 'editorial-glow',
'title' => 'Editorial Glow', 'title' => 'Editorial Glow',
'quote_text' => 'Design with restraint, then let one accent do the speaking.', 'quote_text' => 'Design with restraint, then let one accent do the speaking.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Editorial Kit', 'quote_source' => 'Editorial Kit',
'description' => 'A crisp editorial-format demo card for official collections.', 'description' => 'A crisp editorial-format demo card for official collections.',
'category_slug' => 'motivation', 'category_slug' => 'motivation',
@@ -115,7 +115,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'story-bloom', 'slug' => 'story-bloom',
'title' => 'Story Bloom', 'title' => 'Story Bloom',
'quote_text' => 'If the layout breathes, the words can reach further.', 'quote_text' => 'If the layout breathes, the words can reach further.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Story Vertical Pack', 'quote_source' => 'Story Vertical Pack',
'description' => 'A vertical story-oriented demo card for public browsing and challenges.', 'description' => 'A vertical story-oriented demo card for public browsing and challenges.',
'category_slug' => 'healing', 'category_slug' => 'healing',
@@ -133,7 +133,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'remix-launch-variant', 'slug' => 'remix-launch-variant',
'title' => 'Remix Launch Variant', 'title' => 'Remix Launch Variant',
'quote_text' => 'Take the spark and give it a new rhythm.', 'quote_text' => 'Take the spark and give it a new rhythm.',
'quote_author' => 'Skinbase Nova', 'quote_author' => 'Skinbase',
'quote_source' => 'Remix Lab', 'quote_source' => 'Remix Lab',
'description' => 'A seeded remix showing lineage in demo content.', 'description' => 'A seeded remix showing lineage in demo content.',
'category_slug' => 'motivation', 'category_slug' => 'motivation',
@@ -262,7 +262,7 @@ class NovaCardDemoSeeder extends Seeder
['user_id' => $user->id, 'slug' => 'editorial-favorites'], ['user_id' => $user->id, 'slug' => 'editorial-favorites'],
[ [
'name' => 'Editorial Favorites', 'name' => 'Editorial Favorites',
'description' => 'Officially curated Nova Cards spotlighting launch visuals, remixes, and story-first layouts.', 'description' => 'Officially curated Cards spotlighting launch visuals, remixes, and story-first layouts.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC, 'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true, 'official' => true,
'featured' => true, 'featured' => true,

View File

@@ -1,5 +1,5 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nginx upstream / gateway error pages for Skinbase Nova # Nginx upstream / gateway error pages for Skinbase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Purpose: # Purpose:
# Serve a Nova-styled static HTML page for nginx-level upstream failures such # Serve a Nova-styled static HTML page for nginx-level upstream failures such

View File

@@ -1,6 +1,6 @@
# AI Biography # AI Biography
AI Biography is the Skinbase Nova feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one. AI Biography is the Skinbase feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one.
This document explains how the feature works, what commands are available, where output is stored, and where users can see it. This document explains how the feature works, what commands are available, where output is stored, and where users can see it.

View File

@@ -1,6 +1,6 @@
# Realtime Messaging # Realtime Messaging
Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility. Skinbase messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
## v2 capabilities ## v2 capabilities

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link, usePage } from '@inertiajs/react' import { Link, usePage } from '@inertiajs/react'
import SeoHead from '../components/seo/SeoHead'
import NovaSelect from '../components/ui/NovaSelect' import NovaSelect from '../components/ui/NovaSelect'
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents' import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
@@ -268,6 +269,13 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const pathname = url.split('?')[0] const pathname = url.split('?')[0]
const pageTitle = title ? `${title} — Creator Studio` : 'Creator Studio'
const pageDescription = subtitle || 'Manage your creator workspace from one shared Creator Studio surface.'
const pageSeo = {
title: pageTitle,
description: pageDescription,
robots: 'noindex,nofollow',
}
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : [] const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
const currentGroup = props.studioGroup || null const currentGroup = props.studioGroup || null
const userLabel = props.auth?.user?.name || props.auth?.user?.username || 'Your creator workspace' const userLabel = props.auth?.user?.name || props.auth?.user?.username || 'Your creator workspace'
@@ -391,6 +399,7 @@ export default function StudioLayout({ children, title, subtitle, actions }) {
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_35%),linear-gradient(180deg,_#06101d_0%,_#020617_45%,_#02040a_100%)]"> <div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_35%),linear-gradient(180deg,_#06101d_0%,_#020617_45%,_#02040a_100%)]">
<SeoHead seo={pageSeo} />
<div className="sticky top-16 z-30 border-b border-white/10 bg-slate-950/80 backdrop-blur-xl lg:hidden"> <div className="sticky top-16 z-30 border-b border-white/10 bg-slate-950/80 backdrop-blur-xl lg:hidden">
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<div> <div>

View File

@@ -66,7 +66,7 @@ export default function CollectionAnalytics() {
return ( return (
<> <>
<Head> <Head>
<title>{seo.title || `${collection.title || 'Collection'} Analytics — Skinbase Nova`}</title> <title>{seo.title || `${collection.title || 'Collection'} Analytics — Skinbase`}</title>
<meta name="description" content={seo.description || 'Collection analytics overview.'} /> <meta name="description" content={seo.description || 'Collection analytics overview.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null} {seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} /> <meta name="robots" content={seo.robots || 'noindex,follow'} />

View File

@@ -527,7 +527,7 @@ export default function CollectionDashboard() {
return ( return (
<> <>
<Head> <Head>
<title>{seo.title || 'Collections Dashboard — Skinbase Nova'}</title> <title>{seo.title || 'Collections Dashboard — Skinbase'}</title>
<meta name="description" content={seo.description || 'Collection lifecycle and performance dashboard.'} /> <meta name="description" content={seo.description || 'Collection lifecycle and performance dashboard.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null} {seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} /> <meta name="robots" content={seo.robots || 'noindex,follow'} />

View File

@@ -255,7 +255,7 @@ export default function CollectionFeaturedIndex() {
const seo = props.seo || {} const seo = props.seo || {}
const eyebrow = props.eyebrow || 'Discovery' const eyebrow = props.eyebrow || 'Discovery'
const title = props.title || 'Featured collections' const title = props.title || 'Featured collections'
const description = props.description || 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.' const description = props.description || 'A rotating set of standout galleries from across Skinbase. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.'
const collections = Array.isArray(props.collections) ? props.collections : [] const collections = Array.isArray(props.collections) ? props.collections : []
const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : [] const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : []
const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : [] const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : []
@@ -288,7 +288,7 @@ export default function CollectionFeaturedIndex() {
return ( return (
<> <>
<SeoHead seo={seo} title={seo?.title || `${title} — Skinbase Nova`} description={seo?.description || description} jsonLd={listSchema} /> <SeoHead seo={seo} title={seo?.title || `${title} — Skinbase`} description={seo?.description || description} jsonLd={listSchema} />
<div className="relative min-h-screen overflow-hidden pb-16"> <div className="relative min-h-screen overflow-hidden pb-16">
<div <div

View File

@@ -88,7 +88,7 @@ export default function CollectionHistory() {
return ( return (
<> <>
<Head> <Head>
<title>{seo.title || `${collection.title || 'Collection'} History — Skinbase Nova`}</title> <title>{seo.title || `${collection.title || 'Collection'} History — Skinbase`}</title>
<meta name="description" content={seo.description || 'Collection audit history.'} /> <meta name="description" content={seo.description || 'Collection audit history.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null} {seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} /> <meta name="robots" content={seo.robots || 'noindex,follow'} />

View File

@@ -1766,7 +1766,7 @@ export default function CollectionManage() {
return ( return (
<> <>
<Head> <Head>
<title>{mode === 'create' ? 'Create Collection — Skinbase Nova' : `${collectionState?.title || 'Collection'} — Manage Collection`}</title> <title>{mode === 'create' ? 'Create Collection — Skinbase' : `${collectionState?.title || 'Collection'} — Manage Collection`}</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</Head> </Head>

View File

@@ -19,14 +19,14 @@ export default function CollectionSeriesShow() {
const { props } = usePage() const { props } = usePage()
const seo = props.seo || {} const seo = props.seo || {}
const title = props.title || `Collection Series: ${props.seriesKey || ''}` const title = props.title || `Collection Series: ${props.seriesKey || ''}`
const description = props.description || 'A connected sequence of public collections on Skinbase Nova.' const description = props.description || 'A connected sequence of public collections on Skinbase.'
const collections = Array.isArray(props.collections) ? props.collections : [] const collections = Array.isArray(props.collections) ? props.collections : []
const leadCollection = props.leadCollection || null const leadCollection = props.leadCollection || null
const stats = props.stats || {} const stats = props.stats || {}
return ( return (
<> <>
<SeoHead seo={seo} title={seo.title || `${title} — Skinbase Nova`} description={seo.description || description} /> <SeoHead seo={seo} title={seo.title || `${title} — Skinbase`} description={seo.description || description} />
<div className="relative min-h-screen overflow-hidden pb-16"> <div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} /> <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />

View File

@@ -552,7 +552,7 @@ export default function CollectionShow() {
const enabledModuleKeys = new Set(enabledModules.map((module) => module?.key).filter(Boolean)) const enabledModuleKeys = new Set(enabledModules.map((module) => module?.key).filter(Boolean))
const showIntroBlock = enabledModuleKeys.size === 0 || enabledModuleKeys.has('intro_block') const showIntroBlock = enabledModuleKeys.size === 0 || enabledModuleKeys.has('intro_block')
const metaOwnerName = owner?.name || owner?.username || collection?.owner?.name || 'Skinbase Curator' const metaOwnerName = owner?.name || owner?.username || collection?.owner?.name || 'Skinbase Curator'
const metaTitle = seo?.title || `${collection?.title} — Skinbase Nova` const metaTitle = seo?.title || `${collection?.title} — Skinbase`
const metaDescription = seo?.description || collection?.summary || collection?.description || '' const metaDescription = seo?.description || collection?.summary || collection?.description || ''
const collectionSchema = seo?.canonical ? { const collectionSchema = seo?.canonical ? {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -563,7 +563,7 @@ export default function CollectionShow() {
image: seo?.og_image || collection?.cover_image || undefined, image: seo?.og_image || collection?.cover_image || undefined,
isPartOf: { isPartOf: {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Skinbase Nova', name: 'Skinbase',
url: typeof window !== 'undefined' ? window.location.origin : undefined, url: typeof window !== 'undefined' ? window.location.origin : undefined,
}, },
author: owner ? { author: owner ? {
@@ -772,7 +772,7 @@ export default function CollectionShow() {
setCollection((current) => ({ ...current, shares_count: payload?.shares_count ?? current.shares_count })) setCollection((current) => ({ ...current, shares_count: payload?.shares_count ?? current.shares_count }))
await share({ await share({
title: collection?.title, title: collection?.title,
text: collection?.summary || collection?.description || `Explore ${collection?.title} on Skinbase Nova.`, text: collection?.summary || collection?.description || `Explore ${collection?.title} on Skinbase.`,
url: collection?.public_url, url: collection?.public_url,
}) })
} catch (error) { } catch (error) {

View File

@@ -458,7 +458,7 @@ export default function CollectionStaffProgramming() {
return ( return (
<> <>
<Head> <Head>
<title>{seo.title || 'Collection Programming — Skinbase Nova'}</title> <title>{seo.title || 'Collection Programming — Skinbase'}</title>
<meta name="description" content={seo.description || 'Staff programming tools for collections.'} /> <meta name="description" content={seo.description || 'Staff programming tools for collections.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null} {seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} /> <meta name="robots" content={seo.robots || 'noindex,follow'} />

View File

@@ -374,7 +374,7 @@ export default function CollectionStaffSurfaces() {
return ( return (
<> <>
<Head> <Head>
<title>{seo.title || 'Collection Surfaces — Skinbase Nova'}</title> <title>{seo.title || 'Collection Surfaces — Skinbase'}</title>
<meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} /> <meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null} {seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} /> <meta name="robots" content={seo.robots || 'noindex,follow'} />

View File

@@ -144,7 +144,7 @@ export default function SavedCollections() {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: 'Saved collections', name: 'Saved collections',
description: seo?.description || 'Your saved collections on Skinbase Nova.', description: seo?.description || 'Your saved collections on Skinbase.',
url: seo.canonical, url: seo.canonical,
mainEntity: { mainEntity: {
'@type': 'ItemList', '@type': 'ItemList',
@@ -328,7 +328,7 @@ export default function SavedCollections() {
return ( return (
<> <>
<SeoHead seo={seo} title={seo?.title || 'Saved Collections — Skinbase Nova'} description={seo?.description || 'Your saved collections on Skinbase Nova.'} jsonLd={listSchema} /> <SeoHead seo={seo} title={seo?.title || 'Saved Collections — Skinbase'} description={seo?.description || 'Your saved collections on Skinbase.'} jsonLd={listSchema} />
<div className="relative min-h-screen overflow-hidden pb-16"> <div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} /> <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />

View File

@@ -146,7 +146,7 @@ export default function GroupHelpPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Groups documentation</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Groups documentation</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Build, manage, and publish through Groups without losing personal credit.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Build, manage, and publish through Groups without losing personal credit.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Groups on Skinbase Nova are shared creative identities for studios, collectives, release teams, and long-term collaborations. This guide explains when to use them, how to structure roles, how publishing works, and how to keep the public page clear, trustworthy, and easy to maintain.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Groups on Skinbase are shared creative identities for studios, collectives, release teams, and long-term collaborations. This guide explains when to use them, how to structure roles, how publishing works, and how to keep the public page clear, trustworthy, and easy to maintain.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={links.create_group} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Group</a> <a href={links.create_group} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Group</a>
<a href={links.group_studio} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Group Studio</a> <a href={links.group_studio} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Group Studio</a>

View File

@@ -117,7 +117,7 @@ export default function AuthHelpPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Signup and login help</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Signup and login help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Account access should feel clear, fixable, and much less stressful than it often does.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Account access should feel clear, fixable, and much less stressful than it often does.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains how signup, login, password recovery, and account verification basics work on Skinbase Nova so you can get into your account, recover it when needed, and separate true access problems from workflow or permission confusion.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains how signup, login, password recovery, and account verification basics work on Skinbase so you can get into your account, recover it when needed, and separate true access problems from workflow or permission confusion.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={signedIn ? links.open_studio : links.login} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">{signedIn ? 'Open Studio' : 'Open login'}</a> <a href={signedIn ? links.open_studio : links.login} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">{signedIn ? 'Open Studio' : 'Open login'}</a>
<a href={links.register} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Create account</a> <a href={links.register} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Create account</a>

View File

@@ -121,7 +121,7 @@ export default function CardsHelpPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Cards help</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Cards help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Cards are for ideas that need design, presentation, and message to work together.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Cards are for ideas that need design, presentation, and message to work together.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, how to create and publish them, and how to use them well in both personal and Group workflows.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what Cards are on Skinbase, how they differ from artworks, posts, and collections, how to create and publish them, and how to use them well in both personal and Group workflows.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={links.create_card} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Card</a> <a href={links.create_card} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a Card</a>
<a href={links.studio_cards} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Cards workspace</a> <a href={links.studio_cards} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open Cards workspace</a>

View File

@@ -119,9 +119,9 @@ export default function HelpCenterPage() {
<section id="introduction" className="rounded-[38px] border border-white/10 bg-[linear-gradient(140deg,rgba(15,23,42,0.94),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.18),transparent_26%)] p-6 shadow-[0_32px_100px_rgba(2,6,23,0.34)] md:p-8 lg:p-10"> <section id="introduction" className="rounded-[38px] border border-white/10 bg-[linear-gradient(140deg,rgba(15,23,42,0.94),rgba(15,23,42,0.72)),radial-gradient(circle_at_top_right,rgba(125,211,252,0.18),transparent_26%)] p-6 shadow-[0_32px_100px_rgba(2,6,23,0.34)] md:p-8 lg:p-10">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase Nova Help Center</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase Help Center</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Find the right guide, quickstart, FAQ, or fix without digging through scattered help.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Find the right guide, quickstart, FAQ, or fix without digging through scattered help.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This is the central help hub for Skinbase Nova. Use it to get started, find module-specific guidance, open the live Groups documentation set, and move quickly toward the next useful answer.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This is the central help hub for Skinbase. Use it to get started, find module-specific guidance, open the live Groups documentation set, and move quickly toward the next useful answer.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href="#featured-guides" className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse help topics</a> <a href="#featured-guides" className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse help topics</a>

View File

@@ -119,7 +119,7 @@ export default function ProfileHelpPage() {
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile help</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Your profile is the personal identity people remember when they discover your work.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Your profile is the personal identity people remember when they discover your work.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what a profile is on Skinbase Nova, how it differs from a Group, how to set it up well, and how to build a stronger public creator presence without turning the page into noise.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This page explains what a profile is on Skinbase, how it differs from a Group, how to set it up well, and how to build a stronger public creator presence without turning the page into noise.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={links.profile_settings} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open profile settings</a> <a href={links.profile_settings} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open profile settings</a>
<a href={links.groups_help} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read Groups help</a> <a href={links.groups_help} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Read Groups help</a>

View File

@@ -145,7 +145,7 @@ export default function StudioHelpPage() {
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Studio help</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Studio help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Studio is the creative control center of Skinbase Nova.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Studio is the creative control center of Skinbase.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Use Studio to manage drafts, uploads, publishing, artworks, cards, collections, and collaborative work before and after it goes public. This page explains how Studio fits into the platform, how personal and Group contexts differ, and how to use the workspace without creating avoidable confusion.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Use Studio to manage drafts, uploads, publishing, artworks, cards, collections, and collaborative work before and after it goes public. This page explains how Studio fits into the platform, how personal and Group contexts differ, and how to use the workspace without creating avoidable confusion.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={links.open_studio} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open Studio</a> <a href={links.open_studio} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Open Studio</a>

View File

@@ -122,7 +122,7 @@ export default function WorldsHelpPage() {
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px]">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Worlds help</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Worlds help</p>
<h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Worlds are where Skinbase Nova turns curated content into a live campaign surface.</h1> <h1 className="mt-3 max-w-4xl text-4xl font-semibold tracking-[-0.04em] text-white md:text-5xl xl:text-6xl">Worlds are where Skinbase turns curated content into a live campaign surface.</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This guide explains what Worlds are, how attached content works, how section visibility and order shape the result, and how to preview, publish, promote, and reuse Worlds for recurring campaigns.</p> <p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">This guide explains what Worlds are, how attached content works, how section visibility and order shape the result, and how to preview, publish, promote, and reuse Worlds for recurring campaigns.</p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<a href={links.create_world} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a World</a> <a href={links.create_world} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Create a World</a>

View File

@@ -14,7 +14,7 @@ export const HERO_METRICS = [
{ {
label: 'What this unlocks', label: 'What this unlocks',
value: 'Identity and workspace access', value: 'Identity and workspace access',
note: 'Signup and login are how you reach your profile, enter Studio, and manage the rest of your creator workflow on Skinbase Nova.', note: 'Signup and login are how you reach your profile, enter Studio, and manage the rest of your creator workflow on Skinbase.',
}, },
{ {
label: 'Most common blocker', label: 'Most common blocker',

View File

@@ -73,7 +73,7 @@ export const HIGHLIGHTED_GUIDES = [
{ {
eyebrow: 'Live now', eyebrow: 'Live now',
title: 'Profile help', title: 'Profile help',
description: 'A creator-friendly guide to personal identity, profile setup, profile-versus-Group clarity, and stronger public presentation on Skinbase Nova.', description: 'A creator-friendly guide to personal identity, profile setup, profile-versus-Group clarity, and stronger public presentation on Skinbase.',
status: 'Guide', status: 'Guide',
tone: 'white', tone: 'white',
primaryLinkKey: 'help_profile', primaryLinkKey: 'help_profile',
@@ -292,7 +292,7 @@ export const HELP_CATEGORIES = [
id: 'creation-and-publishing', id: 'creation-and-publishing',
label: 'Creation & publishing', label: 'Creation & publishing',
title: 'Creation and publishing', title: 'Creation and publishing',
summary: 'The main surfaces creators use to make, edit, organize, and publish work on Skinbase Nova.', summary: 'The main surfaces creators use to make, edit, organize, and publish work on Skinbase.',
topics: [ topics: [
{ {
eyebrow: 'Workspace', eyebrow: 'Workspace',

View File

@@ -173,7 +173,7 @@ export const COMMON_MISTAKES = [
export const FAQ_ITEMS = [ export const FAQ_ITEMS = [
{ {
question: 'What is Studio?', question: 'What is Studio?',
answer: 'Studio is the private creator workspace on Skinbase Nova. It is where you manage drafts, uploads, publishing, cards, collections, settings, and other operational parts of your creative work.', answer: 'Studio is the private creator workspace on Skinbase. It is where you manage drafts, uploads, publishing, cards, collections, settings, and other operational parts of your creative work.',
}, },
{ {
question: 'Why do Personal Studio and Group Studio look different?', question: 'Why do Personal Studio and Group Studio look different?',

View File

@@ -146,7 +146,7 @@ export const SECTION_ITEMS_DETAIL = [
{ title: 'Challenge spotlight', body: 'Challenges attached to the campaign or recent participation around it.' }, { title: 'Challenge spotlight', body: 'Challenges attached to the campaign or recent participation around it.' },
{ title: 'Related events', body: 'Upcoming or recent sessions, launches, and live moments.' }, { title: 'Related events', body: 'Upcoming or recent sessions, launches, and live moments.' },
{ title: 'Release spotlights', body: 'Projects and releases that belong in the campaign space.' }, { title: 'Release spotlights', body: 'Projects and releases that belong in the campaign space.' },
{ title: 'Themed cards', body: 'Nova Cards that extend the World identity into designed communication surfaces.' }, { title: 'Themed cards', body: 'Cards that extend the World identity into designed communication surfaces.' },
] ]
export const RELATION_TYPE_ITEMS = [ export const RELATION_TYPE_ITEMS = [
@@ -210,7 +210,7 @@ export const COMMON_MISTAKES = [
export const FAQ_ITEMS = [ export const FAQ_ITEMS = [
{ {
question: 'What is a World on Skinbase Nova?', question: 'What is a World on Skinbase?',
answer: 'A World is a curated editorial destination for a seasonal moment, event, tribute, or campaign. It combines one strong hero with a controlled set of attached modules and optional promotion across public surfaces.', answer: 'A World is a curated editorial destination for a seasonal moment, event, tribute, or campaign. It combines one strong hero with a controlled set of attached modules and optional promotion across public surfaces.',
}, },
{ {

View File

@@ -10,7 +10,7 @@ export default function HomeHero({ artwork }) {
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" />
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16"> <div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl"> <h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
Skinbase Nova Skinbase
</h1> </h1>
<p className="mt-2 max-w-xl text-sm text-soft"> <p className="mt-2 max-w-xl text-sm text-soft">
Discover. Create. Inspire. Discover. Create. Inspire.

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import TagInput from '../../components/tags/TagInput' import TagInput from '../../components/tags/TagInput'
import UploadWizard from '../../components/upload/UploadWizard' import UploadWizard from '../../components/upload/UploadWizard'
import Checkbox from '../../Components/ui/Checkbox' import Checkbox from '../../Components/ui/Checkbox'
@@ -620,6 +621,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) { export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) {
const { props } = usePage() const { props } = usePage()
const pageTitle = 'Upload Artwork — Creator Studio'
const pageDescription = 'Submit a new artwork, complete the required metadata, and publish it from Skinbase Creator Studio.'
const windowFlags = window?.SKINBASE_FLAGS || {} const windowFlags = window?.SKINBASE_FLAGS || {}
const propFlagRaw = props?.feature_flags?.uploads_v2 const propFlagRaw = props?.feature_flags?.uploads_v2
@@ -640,6 +643,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
if (uploadsV2Enabled) { if (uploadsV2Enabled) {
return ( return (
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100"> <section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
<SeoHead seo={{ title: pageTitle, description: pageDescription, robots: 'noindex, nofollow' }} />
<div className="relative isolate"> <div className="relative isolate">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" /> <div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
@@ -825,6 +829,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
)} )}
<div className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]"> <div className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]">
<div className="space-y-6"> <div className="space-y-6">
<SeoHead seo={{ title: pageTitle, description: pageDescription, robots: 'noindex, nofollow' }} />
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_0_50px_rgba(59,130,246,0.15)]"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_0_50px_rgba(59,130,246,0.15)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold tracking-tight">Upload artwork</h1> <h1 className="text-3xl font-semibold tracking-tight">Upload artwork</h1>

View File

@@ -12,11 +12,11 @@ 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 Nova'} description={props.seo?.description || props.description} image={props.seo?.image} /> <SeoHead title={props.seo?.title || 'Worlds - Skinbase'} description={props.seo?.description || props.description} image={props.seo?.image} />
<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">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Skinbase Nova Worlds</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Skinbase Worlds</p>
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl">Curated spaces for seasonal culture, scene moments, and editorial campaigns.</h1> <h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl">Curated spaces for seasonal culture, scene moments, and editorial campaigns.</h1>
<p className="mt-5 max-w-3xl text-base leading-7 text-slate-300">Worlds bundle together artworks, collections, creators, groups, cards, releases, events, challenges, and newsroom context into a single themed destination. They are not filters. They are editorial environments.</p> <p className="mt-5 max-w-3xl text-base leading-7 text-slate-300">Worlds bundle together artworks, collections, creators, groups, cards, releases, events, challenges, and newsroom context into a single themed destination. They are not filters. They are editorial environments.</p>
</div> </div>
@@ -50,7 +50,7 @@ export default function WorldIndex() {
<WorldsIndexSection <WorldsIndexSection
title="Active Worlds" title="Active Worlds"
description="Live worlds and currently running campaign surfaces across Skinbase Nova." description="Live worlds and currently running campaign surfaces across Skinbase."
items={props.activeWorlds} items={props.activeWorlds}
emptyMessage="No worlds are currently live. Check upcoming programming below." emptyMessage="No worlds are currently live. Check upcoming programming below."
sourceSurface="worlds_index" sourceSurface="worlds_index"

View File

@@ -227,7 +227,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 Nova`} description={props.seo?.description || world?.summary} image={props.seo?.image} /> <SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
{previewMode ? ( {previewMode ? (
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50"> <section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50">

View File

@@ -5,7 +5,7 @@ import { cx, formatCompactNumber } from './groupStyles'
export default function GroupDiscoveryCard({ group, className = '', compact = false }) { export default function GroupDiscoveryCard({ group, className = '', compact = false }) {
if (!group) return null if (!group) return null
const primarySummary = group.headline || group.bio_excerpt || 'Collaborative publishing identity on Skinbase Nova.' const primarySummary = group.headline || group.bio_excerpt || 'Collaborative publishing identity on Skinbase.'
return ( return (
<a <a

View File

@@ -143,7 +143,7 @@ export default function HomepageAnnouncement({ announcement, mode = 'live' }) {
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-semibold text-white/90 transition hover:border-white/20 hover:bg-white/[0.1]" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-semibold text-white/90 transition hover:border-white/20 hover:bg-white/[0.1]"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span>Show Skinbase Nova announcement</span> <span>Show Skinbase announcement</span>
</button> </button>
</div> </div>
</section> </section>

View File

@@ -59,7 +59,7 @@ export default function NovaConfirmDialog({
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]" className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
> >
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5"> <div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Skinbase Nova</p> <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Skinbase</p>
<h3 id="nova-confirm-title" className="mt-2 text-lg font-semibold text-white"> <h3 id="nova-confirm-title" className="mt-2 text-lg font-semibold text-white">
{title} {title}
</h3> </h3>

View File

@@ -1157,7 +1157,7 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
<div className="absolute inset-0 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_transparent_40%,_rgba(245,158,11,0.10)_100%)]" /> <div className="absolute inset-0 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_transparent_40%,_rgba(245,158,11,0.10)_100%)]" />
<div className="relative grid gap-8 xl:grid-cols-[1.35fr_0.95fr] xl:items-start"> <div className="relative grid gap-8 xl:grid-cols-[1.35fr_0.95fr] xl:items-start">
<div> <div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Nova Dashboard</p> <p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80"> Dashboard</p>
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl"> <h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Welcome back, {username} Welcome back, {username}
</h1> </h1>

View File

@@ -29,7 +29,7 @@
--panel-dark: #151E2E; --panel-dark: #151E2E;
--soft-blue: #7A8CA5; --soft-blue: #7A8CA5;
--accent-orange: #E07A21; --accent-orange: #E07A21;
/* Toolbar color (Skinbase Nova) */ /* Toolbar color (Skinbase) */
--toolbar-bg: #0F1724; --toolbar-bg: #0F1724;
/* RGB variants for subtle overlays */ /* RGB variants for subtle overlays */
--nova-blue-rgb: 100,111,131; --nova-blue-rgb: 100,111,131;

View File

@@ -6,7 +6,7 @@
->addJsonLd([ ->addJsonLd([
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'CollectionPage', '@type' => 'CollectionPage',
'name' => $meta['title'] ?? 'Nova Cards - Skinbase Nova', 'name' => $meta['title'] ?? 'Cards - Skinbase',
'description' => $meta['description'] ?? '', 'description' => $meta['description'] ?? '',
'url' => $meta['canonical'] ?? route('cards.index'), 'url' => $meta['canonical'] ?? route('cards.index'),
'isPartOf' => [ 'isPartOf' => [
@@ -35,7 +35,7 @@
@section('content') @section('content')
<section class="px-6 pt-8 md:px-10"> <section class="px-6 pt-8 md:px-10">
<div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8"> <div class="rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)] md:p-8">
<p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Nova Cards</p> <p class="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Cards</p>
<h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $heading }}</h1> <h1 class="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{{ $heading }}</h1>
<p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $subheading }}</p> <p class="mt-4 max-w-3xl text-sm leading-7 text-slate-300 md:text-base">{{ $subheading }}</p>
<div class="mt-6 flex flex-wrap gap-3"> <div class="mt-6 flex flex-wrap gap-3">
@@ -118,7 +118,7 @@
<div> <div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator profile</p> <p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Creator profile</p>
<h2 class="mt-1 text-2xl font-semibold text-white">{{ $creatorSummary['creator']['display_name'] }}</h2> <h2 class="mt-1 text-2xl font-semibold text-white">{{ $creatorSummary['creator']['display_name'] }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-300">{{ ($context ?? null) === 'creator-portfolio' ? 'A dedicated Nova Cards portfolio view with public works, signature themes, remix activity, and publishing history.' : 'A public snapshot of this creator\'s Nova Cards footprint, top styles, and strongest publishing signals.' }}</p> <p class="mt-2 text-sm leading-7 text-slate-300">{{ ($context ?? null) === 'creator-portfolio' ? 'A dedicated Cards portfolio view with public works, signature themes, remix activity, and publishing history.' : 'A public snapshot of this creator\'s Cards footprint, top styles, and strongest publishing signals.' }}</p>
</div> </div>
<span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{{ '@' . $creatorSummary['creator']['username'] }}</span> <span class="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{{ '@' . $creatorSummary['creator']['username'] }}</span>
</div> </div>
@@ -266,7 +266,7 @@
@endforeach @endforeach
</div> </div>
@else @else
<div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Challenge entries and featured placements will appear here as this creator participates in Nova Cards challenges.</div> <div class="mt-4 rounded-[20px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-6 text-sm text-slate-400">Challenge entries and featured placements will appear here as this creator participates in Cards challenges.</div>
@endif @endif
</div> </div>
</div> </div>
@@ -631,7 +631,7 @@
<i class="fa-solid fa-id-card text-3xl"></i> <i class="fa-solid fa-id-card text-3xl"></i>
</div> </div>
<h3 class="mt-5 text-2xl font-semibold text-white">No public cards yet</h3> <h3 class="mt-5 text-2xl font-semibold text-white">No public cards yet</h3>
<p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">As creators publish their Nova Cards, they will appear here with crawlable quote text and preview imagery.</p> <p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-slate-300">As creators publish their Cards, they will appear here with crawlable quote text and preview imagery.</p>
</div> </div>
@else @else
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">

View File

@@ -6,8 +6,8 @@
->addJsonLd([ ->addJsonLd([
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'CollectionPage', '@type' => 'CollectionPage',
'name' => $meta['title'] ?? 'Nova Card Lineage - Skinbase Nova', 'name' => $meta['title'] ?? 'Nova Lineage - Skinbase',
'description' => $meta['description'] ?? 'Trace the remix lineage for this Nova Card.', 'description' => $meta['description'] ?? 'Trace the remix lineage for this Card.',
'url' => $meta['canonical'] ?? route('cards.lineage', ['slug' => $card['slug'], 'id' => $card['id']]), 'url' => $meta['canonical'] ?? route('cards.lineage', ['slug' => $card['slug'], 'id' => $card['id']]),
'mainEntity' => collect($familyCards ?? [])->map(fn ($familyCard) => [ 'mainEntity' => collect($familyCards ?? [])->map(fn ($familyCard) => [
'@type' => 'CreativeWork', '@type' => 'CreativeWork',

View File

@@ -5,7 +5,7 @@
) )
->og( ->og(
type: 'article', type: 'article',
title: $meta['title'] ?? ($card['title'] . ' - Nova Cards - Skinbase Nova'), title: $meta['title'] ?? ($card['title'] . ' - Cards - Skinbase'),
description: $meta['description'] ?? $card['quote_text'], description: $meta['description'] ?? $card['quote_text'],
url: $meta['canonical'] ?? $card['public_url'], url: $meta['canonical'] ?? $card['public_url'],
image: $card['og_preview_url'] ?? $card['preview_url'] ?? null, image: $card['og_preview_url'] ?? $card['preview_url'] ?? null,

View File

@@ -2,15 +2,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ app()->getLocale() }}"> <html lang="{{ app()->getLocale() }}">
<head> <head>
<title>{{ $page_title ?? 'Skinbase' }}</title> @include('partials.seo.head', ['seo' => $seo ?? null])
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{ $page_meta_description ?? '' }}">
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
@isset($page_canonical)
<link rel="canonical" href="{{ $page_canonical }}" />
@endisset
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="shortcut icon" href="/favicon.ico"> <link rel="shortcut icon" href="/favicon.ico">

View File

@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@php($page_robots = $page_robots ?? 'noindex,nofollow')
<title>{{ config('app.name', 'Laravel') }}</title> @include('partials.seo.head', ['seo' => $seo ?? null])
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">

View File

@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@php($page_robots = $page_robots ?? 'noindex,nofollow')
<title>{{ config('app.name', 'Laravel') }}</title> @include('partials.seo.head', ['seo' => $seo ?? null])
<!-- Fonts --> <!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">

View File

@@ -36,16 +36,7 @@
@yield('sidebar') @yield('sidebar')
</aside> </aside>
</div> </div>
@else @include('partials.seo.head', ['seo' => $seo ?? null])
<section class="min-w-0">
@yield('content')
</section>
@endif
</div>
</main>
@include('layouts.nova.footer')
@stack('toolbar') @stack('toolbar')
@stack('scripts') @stack('scripts')

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Skinbase')</title> @php($page_robots = $page_robots ?? 'noindex,nofollow')
@include('partials.seo.head', ['seo' => $seo ?? null])
</head> </head>
<body> <body>
@yield('content') @yield('content')

View File

@@ -6,7 +6,21 @@
$deferFontAwesome = request()->routeIs('index'); $deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index'); $deferWebManifest = request()->routeIs('index');
$isInertiaPage = isset($page) && is_array($page); $isInertiaPage = isset($page) && is_array($page);
$shouldRenderBladeSeo = ($useUnifiedSeo ?? false) && (($renderBladeSeo ?? false) || ! $isInertiaPage); $isAuthSeoRoute = request()->routeIs([
'login',
'register',
'register.notice',
'password.request',
'password.reset',
'password.confirm',
'verification.notice',
'registration.verify',
'setup.email.create',
'setup.password.create',
'setup.username.create',
]);
$page_robots = $page_robots ?? ($isAuthSeoRoute ? 'noindex,nofollow' : null);
$shouldRenderBladeSeo = ($useUnifiedSeo ?? ! $isInertiaPage) && (($renderBladeSeo ?? true) || ! $isInertiaPage);
$novaCssEntries = [ $novaCssEntries = [
'resources/css/app.css', 'resources/css/app.css',
'resources/css/nova-grid.css', 'resources/css/nova-grid.css',

View File

@@ -6,6 +6,10 @@
@php @php
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true; $skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped; $skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
$page_title = $page_title ?? 'Story Editor';
$page_meta_description = $page_meta_description ?? 'Create and edit stories on Skinbase.';
$page_canonical = $page_canonical ?? url()->current();
$page_robots = $page_robots ?? 'noindex,nofollow';
@endphp @endphp
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ app()->getLocale() }}"> <html lang="{{ app()->getLocale() }}">
@@ -16,7 +20,7 @@
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@endif @endif
<title>{{ $page_title ?? 'Story Editor' }} Skinbase</title> @include('partials.seo.head', ['seo' => $seo ?? null])
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />

View File

@@ -1,6 +1,6 @@
@extends('news.layout', [ @extends('news.layout', [
'metaTitle' => $archiveDate->format('F Y') . ' — News Archive', 'metaTitle' => $archiveDate->format('F Y') . ' — News Archive',
'metaDescription' => 'News archive for ' . $archiveDate->format('F Y') . ' on Skinbase Nova.', 'metaDescription' => 'News archive for ' . $archiveDate->format('F Y') . ' on Skinbase.',
'metaCanonical' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]), 'metaCanonical' => route('news.archive', ['year' => $archiveDate->year, 'month' => $archiveDate->month]),
]) ])

View File

@@ -31,7 +31,7 @@
<div> <div>
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/40">News author</div> <div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/40">News author</div>
<h2 class="mt-2 text-2xl font-semibold text-white">{{ $authorLabel }}</h2> <h2 class="mt-2 text-2xl font-semibold text-white">{{ $authorLabel }}</h2>
<p class="mt-2 text-sm leading-7 text-white/60">{{ $author->profile?->bio ? Str::limit($author->profile->bio, 180) : 'Writes updates, announcements, and editorial stories for Skinbase Nova.' }}</p> <p class="mt-2 text-sm leading-7 text-white/60">{{ $author->profile?->bio ? Str::limit($author->profile->bio, 180) : 'Writes updates, announcements, and editorial stories for Skinbase.' }}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
@endif @endif
@vite(['resources/js/studio.jsx']) @vite(['resources/js/studio.jsx'])
@inertiaHead
<style> <style>
body.page-studio main { padding-top: 2.3rem; } body.page-studio main { padding-top: 2.3rem; }
</style> </style>

View File

@@ -15,6 +15,7 @@
</script> </script>
@vite(['resources/js/entry-topbar.jsx','resources/js/upload.jsx']) @vite(['resources/js/entry-topbar.jsx','resources/js/upload.jsx'])
@inertiaHead
<style> <style>
/* Upload page spacing: extra top padding and bottom space so sticky action bar won't overlap content */ /* Upload page spacing: extra top padding and bottom space so sticky action bar won't overlap content */
body.page-upload main { body.page-upload main {

View File

@@ -11,7 +11,7 @@
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div> <div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div>
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16"> <div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<h1 class="text-2xl font-bold tracking-tight text-white sm:text-4xl"> <h1 class="text-2xl font-bold tracking-tight text-white sm:text-4xl">
Skinbase Nova Skinbase
</h1> </h1>
<p class="mt-2 max-w-xl text-sm text-soft"> <p class="mt-2 max-w-xl text-sm text-soft">
Discover. Create. Inspire. Discover. Create. Inspire.

View File

@@ -1099,6 +1099,9 @@ fi
log_step "Restarting queue workers" log_step "Restarting queue workers"
"$PHP_BIN" artisan queue:restart || true "$PHP_BIN" artisan queue:restart || true
log_step "Restarting Horizon workers"
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
if [[ "${SKIP_SSR_RESTART:-0}" -eq 0 ]]; then if [[ "${SKIP_SSR_RESTART:-0}" -eq 0 ]]; then
log_step "Restarting Inertia SSR server" log_step "Restarting Inertia SSR server"
restart_ssr_with_artisan() { restart_ssr_with_artisan() {

View File

@@ -661,8 +661,8 @@ it('public collection search exposes noindex seo props on filtered pages', funct
->component('Collection/CollectionFeaturedIndex') ->component('Collection/CollectionFeaturedIndex')
->where('seo.robots', 'noindex,follow') ->where('seo.robots', 'noindex,follow')
->where('seo.canonical', $url) ->where('seo.canonical', $url)
->where('seo.title', 'Search Collections — Skinbase Nova') ->where('seo.title', 'Search Collections — Skinbase')
->where('seo.description', 'Search results for "SEO Search Target" across public Skinbase Nova collections.')); ->where('seo.description', 'Search results for "SEO Search Target" across public Skinbase collections.'));
}); });
it('saved collections support private saved notes', function () { it('saved collections support private saved notes', function () {

View File

@@ -16,7 +16,7 @@ beforeEach(function (): void {
function homepageAnnouncement(array $overrides = []): HomepageAnnouncement function homepageAnnouncement(array $overrides = []): HomepageAnnouncement
{ {
return HomepageAnnouncement::query()->create(array_merge([ return HomepageAnnouncement::query()->create(array_merge([
'title' => 'Skinbase Nova is live.', 'title' => 'Skinbase is live.',
'subtitle' => 'A new chapter for the Skinbase creative community.', 'subtitle' => 'A new chapter for the Skinbase creative community.',
'badge_text' => 'Launch Day · 1 May 2026', 'badge_text' => 'Launch Day · 1 May 2026',
'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>', 'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',

View File

@@ -63,8 +63,8 @@ it('renders published news across public discovery routes', function (): void {
'slug' => 'release', 'slug' => 'release',
]); ]);
$article = publishedNewsArticle($author, $category, [ $article = publishedNewsArticle($author, $category, [
'title' => 'Skinbase Nova Newsroom', 'title' => 'Skinbase Newsroom',
'slug' => 'skinbase-nova-newsroom', 'slug' => 'skinbase-newsroom',
]); ]);
$article->tags()->sync([$tag->id]); $article->tags()->sync([$tag->id]);
@@ -80,29 +80,29 @@ it('renders published news across public discovery routes', function (): void {
$this->get(route('news.index')) $this->get(route('news.index'))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom') ->assertSee('Skinbase Newsroom')
->assertDontSee('Hidden Draft'); ->assertDontSee('Hidden Draft');
$this->get(route('news.show', ['slug' => $article->slug])) $this->get(route('news.show', ['slug' => $article->slug]))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom') ->assertSee('Skinbase Newsroom')
->assertSee('News Author'); ->assertSee('News Author');
$this->get(route('news.category', ['slug' => $category->slug])) $this->get(route('news.category', ['slug' => $category->slug]))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom'); ->assertSee('Skinbase Newsroom');
$this->get(route('news.tag', ['slug' => $tag->slug])) $this->get(route('news.tag', ['slug' => $tag->slug]))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom'); ->assertSee('Skinbase Newsroom');
$this->get(route('news.archive', ['year' => $article->published_at->year, 'month' => $article->published_at->month])) $this->get(route('news.archive', ['year' => $article->published_at->year, 'month' => $article->published_at->month]))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom'); ->assertSee('Skinbase Newsroom');
$this->get(route('news.author', ['username' => $author->username])) $this->get(route('news.author', ['username' => $author->username]))
->assertOk() ->assertOk()
->assertSee('Skinbase Nova Newsroom'); ->assertSee('Skinbase Newsroom');
}); });
it('renders a public news article when anonymous sessions are skipped', function (): void { it('renders a public news article when anonymous sessions are skipped', function (): void {

View File

@@ -983,8 +983,8 @@ describe('AiBiographyGenerator v1.1 — retry', function () {
]); ]);
$requests = []; $requests = [];
$firstAttempt = 'szerencsefia has been a member of Skinbase Nova since 2007. They have uploaded one public artwork categorized under GTK+.'; $firstAttempt = 'szerencsefia has been a member of Skinbase since 2007. They have uploaded one public artwork categorized under GTK+.';
$retryAttempt = 'szerencsefia has been part of Skinbase Nova since 2007. They have one public artwork on the platform, and that published work is categorized under GTK+, giving a modest but concrete snapshot of their public activity.'; $retryAttempt = 'szerencsefia has been part of Skinbase since 2007. They have one public artwork on the platform, and that published work is categorized under GTK+, giving a modest but concrete snapshot of their public activity.';
Http::fake(function ($request) use (&$requests, $firstAttempt, $retryAttempt) { Http::fake(function ($request) use (&$requests, $firstAttempt, $retryAttempt) {
$requests[] = $request->data(); $requests[] = $request->data();
@@ -1424,7 +1424,7 @@ describe('GenerateAiBiographyCommand v1.1 — missing batch', function () {
expect($output)->toContain('Prompt preview'); expect($output)->toContain('Prompt preview');
expect($output)->toContain('Provider : vision_gateway'); expect($output)->toContain('Provider : vision_gateway');
expect($output)->toContain('System prompt:'); expect($output)->toContain('System prompt:');
expect($output)->toContain('You are a concise writing assistant for Skinbase Nova'); expect($output)->toContain('You are a concise writing assistant for Skinbase');
expect($output)->toContain('User prompt:'); expect($output)->toContain('User prompt:');
expect($output)->toContain('Write a creator biography in 70 to 130 words'); expect($output)->toContain('Write a creator biography in 70 to 130 words');
}); });

View File

@@ -698,7 +698,7 @@ it('stores a world draft through the studio flow', function (): void {
'badge_label' => 'Editorial pick', 'badge_label' => 'Editorial pick',
'badge_description' => 'Featured by the Nova editorial team.', 'badge_description' => 'Featured by the Nova editorial team.',
'badge_url' => 'https://skinbase.test/badges/retro', 'badge_url' => 'https://skinbase.test/badges/retro',
'seo_title' => 'Retro Month 2026 - Skinbase Nova', 'seo_title' => 'Retro Month 2026 - Skinbase',
'seo_description' => 'Retro Month seasonal campaign', 'seo_description' => 'Retro Month seasonal campaign',
'published_at' => '2026-03-20T10:00', 'published_at' => '2026-03-20T10:00',
'related_tags_json' => ['retro', 'demoscene'], 'related_tags_json' => ['retro', 'demoscene'],

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
uses(RefreshDatabase::class);
it('renders seo tags on public blade pages', function (): void {
$response = $this->get('/');
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('<title>', $html);
$this->assertStringContainsString('meta name="description"', $html);
$this->assertStringContainsString('meta property="og:title"', $html);
$this->assertStringContainsString('meta name="twitter:card"', $html);
});
it('renders seo tags on auth blade pages', function (): void {
$response = $this->get('/login');
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('<title>', $html);
$this->assertStringContainsString('meta name="description"', $html);
$this->assertStringContainsString('meta property="og:title"', $html);
$this->assertStringContainsString('meta name="robots" content="noindex,nofollow"', $html);
});
it('renders seo tags on upload and studio pages', function (): void {
$user = User::factory()->create();
$uploadResponse = $this->actingAs($user)->get('/upload');
$uploadResponse->assertOk();
$uploadHtml = $uploadResponse->getContent();
$this->assertNotFalse($uploadHtml);
$this->assertStringContainsString('<title>Upload Artwork — Creator Studio</title>', $uploadHtml);
$this->assertStringContainsString('meta name="description"', $uploadHtml);
$this->assertStringContainsString('meta property="og:title"', $uploadHtml);
$this->assertStringContainsString('meta name="robots" content="noindex, nofollow"', $uploadHtml);
$studioResponse = $this->actingAs($user)->get('/studio/artworks');
$studioResponse->assertOk();
$studioHtml = $studioResponse->getContent();
$this->assertNotFalse($studioHtml);
$this->assertStringContainsString('Creator Studio', $studioHtml);
$this->assertStringContainsString('meta name="description"', $studioHtml);
$this->assertStringContainsString('meta property="og:title"', $studioHtml);
$this->assertStringContainsString('meta name="robots" content="noindex, nofollow"', $studioHtml);
});

View File

@@ -21,14 +21,15 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa
config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.local'); config()->set('vision.vector_gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.api_key', 'test-key'); config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert'); config()->set('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file');
config()->set('cdn.files_url', 'https://files.local'); config()->set('cdn.files_url', 'https://files.local');
Http::fake([ Http::fake([
'https://clip.local/embed' => Http::response([ 'https://clip.local/embed' => Http::response([
'embedding' => [3.0, 4.0], 'embedding' => [3.0, 4.0],
], 200), ], 200),
'https://vision.local/vectors/upsert' => Http::response([ 'https://files.local/*' => Http::response('fake-image-bytes', 200),
'https://vision.local/vectors/upsert/file' => Http::response([
'status' => 'ok', 'status' => 'ok',
], 200), ], 200),
]); ]);
@@ -62,11 +63,7 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa
$artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.91]); $artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.91]);
$job = new GenerateArtworkEmbeddingJob($artwork->id, 'aabbccddeeff1122'); $job = new GenerateArtworkEmbeddingJob($artwork->id, 'aabbccddeeff1122');
$job->handle( app()->call([$job, 'handle']);
app(\App\Services\Vision\ArtworkEmbeddingClient::class),
app(\App\Services\Vision\ArtworkVisionImageUrl::class),
app(\App\Services\Vision\ArtworkVectorIndexService::class),
);
$embedding = ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->first(); $embedding = ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->first();
$artwork->refresh(); $artwork->refresh();
@@ -81,26 +78,11 @@ it('persists a normalized embedding and upserts the artwork to the vector gatewa
expect(round((float) $vector[0], 4))->toBe(0.6) expect(round((float) $vector[0], 4))->toBe(0.6)
->and(round((float) $vector[1], 4))->toBe(0.8); ->and(round((float) $vector[1], 4))->toBe(0.8);
Http::assertSent(function (\Illuminate\Http\Client\Request $request) use ($artwork): bool { Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool {
if ($request->url() !== 'https://vision.local/vectors/upsert') { return str_contains($request->url(), 'vision.local/vectors/upsert');
return false;
}
$data = $request->data();
return ($data['id'] ?? null) === (string) $artwork->id
&& ($data['url'] ?? null) === 'https://files.local/artworks/md/aa/bb/aabbccddeeff1122.webp'
&& ($data['metadata']['content_type'] ?? null) === 'Wallpapers'
&& ($data['metadata']['category'] ?? null) === 'Abstract'
&& ($data['metadata']['tags'] ?? null) === ['neon']
&& ($data['metadata']['user_id'] ?? null) === (string) $artwork->user_id
&& ($data['metadata']['is_public'] ?? null) === true
&& ($data['metadata']['is_deleted'] ?? null) === false
&& ($data['metadata']['is_nsfw'] ?? null) === false
&& isset($data['metadata']['category_id'])
&& isset($data['metadata']['content_type_id'])
&& array_key_exists('status', $data['metadata']);
}); });
Http::assertSentCount(3);
}); });
it('keeps the local embedding when vector upsert fails', function () { it('keeps the local embedding when vector upsert fails', function () {
@@ -111,14 +93,15 @@ it('keeps the local embedding when vector upsert fails', function () {
config()->set('vision.vector_gateway.enabled', true); config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.local'); config()->set('vision.vector_gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.api_key', 'test-key'); config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert'); config()->set('vision.vector_gateway.upsert_file_endpoint', '/vectors/upsert/file');
config()->set('cdn.files_url', 'https://files.local'); config()->set('cdn.files_url', 'https://files.local');
Http::fake([ Http::fake([
'https://clip.local/embed' => Http::response([ 'https://clip.local/embed' => Http::response([
'embedding' => [1.0, 2.0, 2.0], 'embedding' => [1.0, 2.0, 2.0],
], 200), ], 200),
'https://vision.local/vectors/upsert' => Http::response([ 'https://files.local/*' => Http::response('fake-image-bytes', 200),
'https://vision.local/vectors/upsert/file' => Http::response([
'message' => 'gateway error', 'message' => 'gateway error',
], 500), ], 500),
]); ]);
@@ -132,16 +115,12 @@ it('keeps the local embedding when vector upsert fails', function () {
]); ]);
$job = new GenerateArtworkEmbeddingJob($artwork->id, '1122334455667788'); $job = new GenerateArtworkEmbeddingJob($artwork->id, '1122334455667788');
$job->handle( app()->call([$job, 'handle']);
app(\App\Services\Vision\ArtworkEmbeddingClient::class),
app(\App\Services\Vision\ArtworkVisionImageUrl::class),
app(\App\Services\Vision\ArtworkVectorIndexService::class),
);
$artwork->refresh(); $artwork->refresh();
expect(ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->exists())->toBeTrue() expect(ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->exists())->toBeTrue()
->and($artwork->last_vector_indexed_at)->toBeNull(); ->and($artwork->last_vector_indexed_at)->toBeNull();
Http::assertSentCount(2); Http::assertSentCount(3);
}); });

View File

@@ -5,33 +5,17 @@ declare(strict_types=1);
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson; use function Pest\Laravel\postJson;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('returns normalized synchronous vision tag suggestions for the artwork owner', function (): void { it('returns a disabled payload for the artwork owner while synchronous vision suggestions are off', function (): void {
config()->set('vision.enabled', true); config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
$user = User::factory()->create(); $user = User::factory()->create();
$artwork = Artwork::factory()->create([ $artwork = Artwork::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'hash' => 'aabbcc112233',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'Neon City', 'confidence' => 0.91],
['tag' => 'Night Sky', 'confidence' => 0.77],
],
'yolo' => [
['label' => 'car', 'confidence' => 0.65],
],
], 200),
]); ]);
actingAs($user); actingAs($user);
@@ -39,24 +23,18 @@ it('returns normalized synchronous vision tag suggestions for the artwork owner'
$response = postJson('/api/uploads/' . $artwork->id . '/vision-suggest?limit=10'); $response = postJson('/api/uploads/' . $artwork->id . '/vision-suggest?limit=10');
$response->assertOk() $response->assertOk()
->assertJsonPath('vision_enabled', true) ->assertJsonPath('vision_enabled', false)
->assertJsonPath('source', 'gateway_sync') ->assertJsonPath('reason', 'disabled')
->assertJsonPath('tags.0.slug', 'neon-city') ->assertJsonPath('tags', []);
->assertJsonPath('tags.0.source', 'clip')
->assertJsonPath('tags.1.slug', 'night-sky')
->assertJsonPath('tags.2.slug', 'car');
}); });
it('returns 404 when a non-owner requests upload vision suggestions', function (): void { it('returns 404 when a non-owner requests upload vision suggestions', function (): void {
config()->set('vision.enabled', true); config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
$owner = User::factory()->create(); $owner = User::factory()->create();
$viewer = User::factory()->create(); $viewer = User::factory()->create();
$artwork = Artwork::factory()->create([ $artwork = Artwork::factory()->create([
'user_id' => $owner->id, 'user_id' => $owner->id,
'hash' => 'aabbcc112233',
]); ]);
actingAs($viewer); actingAs($viewer);

View File

@@ -88,7 +88,7 @@ afterEach(function (): void {
function announcementPayload(array $overrides = []): array function announcementPayload(array $overrides = []): array
{ {
return array_merge([ return array_merge([
'title' => 'Skinbase Nova is live.', 'title' => 'Skinbase is live.',
'subtitle' => 'A new chapter for the Skinbase creative community.', 'subtitle' => 'A new chapter for the Skinbase creative community.',
'badge_text' => 'Launch Day · 1 May 2026', 'badge_text' => 'Launch Day · 1 May 2026',
'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>', 'content_html' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
@@ -196,7 +196,7 @@ it('homepage payload includes the announcement prop', function (): void {
'announcement' => [ 'announcement' => [
'id' => 42, 'id' => 42,
'dismiss_version' => 1, 'dismiss_version' => 1,
'title' => 'Skinbase Nova is live.', 'title' => 'Skinbase is live.',
'subtitle' => 'A new chapter.', 'subtitle' => 'A new chapter.',
'badge_text' => 'Launch', 'badge_text' => 'Launch',
'content_html' => '<p>Hello</p>', 'content_html' => '<p>Hello</p>',