publicCardsQuery()->latest('published_at')->paginate(18)->withQueryString(); $featured = $this->publicCardsQuery()->where('featured', true)->latest('published_at')->limit(6)->get(); $trending = $this->publicCardsQuery()->orderByDesc('trending_score')->orderByDesc('last_engaged_at')->limit(6)->get(); $rising = $this->risingService->risingCards(6); $collections = NovaCardCollection::query() ->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']) ->where(function ($query): void { $query->where('official', true) ->orWhere('visibility', NovaCardCollection::VISIBILITY_PUBLIC); }) ->orderByDesc('featured') ->orderByDesc('official') ->orderByDesc('updated_at') ->limit(4) ->get(); $categories = NovaCardCategory::query()->where('active', true)->orderBy('order_num')->orderBy('name')->get(); $tags = NovaCardTag::query()->withCount('cards')->orderByDesc('cards_count')->limit(12)->get(); $styleFamilies = $this->styleFamilies(); return view('cards.index', [ 'meta' => [ 'title' => 'Nova Cards - Skinbase Nova', 'description' => 'Browse featured, trending, and latest Nova Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase Nova community.', 'canonical' => route('cards.index'), 'robots' => 'index,follow', ], 'heading' => 'Nova Cards', 'subheading' => (string) config('nova_cards.brand.subtitle'), 'cards' => $this->presenter->cards($latest->items()), 'pagination' => $latest, 'featuredCards' => $this->presenter->cards($featured), 'trendingCards' => $this->presenter->cards($trending), 'risingCards' => $this->presenter->cards($rising->all()), 'categories' => $categories, 'tags' => $tags, 'moodFamilies' => $this->moodFamilies(), 'styleFamilies' => $styleFamilies, 'paletteFamilies' => $this->paletteFamilies(), 'seasonalHubs' => $this->seasonalHubs(), 'collections' => $collections->map(fn (NovaCardCollection $collection): array => $this->presenter->collection($collection, $request->user()))->values()->all(), 'context' => 'index', ]); } public function category(Request $request, string $categorySlug): View { $category = NovaCardCategory::query()->where('slug', $categorySlug)->where('active', true)->firstOrFail(); $cards = $this->publicCardsQuery()->where('category_id', $category->id)->latest('published_at')->paginate(18)->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => $category->name . ' Cards - Skinbase Nova', 'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Nova Cards on Skinbase Nova.'), 'canonical' => route('cards.category', ['categorySlug' => $category->slug]), 'robots' => 'index,follow', ], 'heading' => $category->name, 'subheading' => $category->description ?: 'Explore this Nova Cards category.', 'cards' => $this->presenter->cards($cards->items()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect([$category]), 'tags' => collect(), 'styleFamilies' => collect(), 'context' => 'category', ]); } public function popular(Request $request): View { $cards = $this->publicCardsQuery() ->orderByDesc('trending_score') ->orderByDesc('likes_count') ->orderByDesc('saves_count') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => 'Popular Cards - Skinbase Nova', 'description' => 'Browse the most liked, saved, and viewed Nova Cards on Skinbase Nova.', 'canonical' => route('cards.popular'), 'robots' => 'index,follow', ], 'heading' => 'Popular cards', 'subheading' => 'The cards earning the strongest saves, likes, and repeat views right now.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'risingCards' => [], 'categories' => collect(), 'tags' => collect(), 'styleFamilies' => collect(), 'context' => 'popular', ]); } public function rising(Request $request): View { $risingCollection = $this->risingService->risingCards(36); $page = max(1, (int) $request->query('page', 1)); $perPage = 18; $paginated = new \Illuminate\Pagination\LengthAwarePaginator( $risingCollection->forPage($page, $perPage)->values(), $risingCollection->count(), $perPage, $page, ['path' => route('cards.rising')], ); return view('cards.index', [ 'meta' => [ 'title' => 'Rising Cards - Skinbase Nova', 'description' => 'Discover Nova Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.', 'canonical' => route('cards.rising'), 'robots' => 'index,follow', ], 'heading' => 'Rising', 'subheading' => 'Fresh Nova Cards gaining momentum right now.', 'cards' => $this->presenter->cards($paginated->items(), false, $request->user()), 'pagination' => $paginated, 'featuredCards' => [], 'trendingCards' => [], 'risingCards' => [], 'categories' => collect(), 'tags' => collect(), 'styleFamilies' => collect(), 'context' => 'rising', ]); } public function remixed(Request $request): View { $cards = $this->publicCardsQuery() ->whereNotNull('original_card_id') ->orderByDesc('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => 'Remixed Cards - Skinbase Nova', 'description' => 'Discover Nova Cards remixed from community originals with attribution and lineage.', 'canonical' => route('cards.remixed'), 'robots' => 'index,follow', ], 'heading' => 'Remixed cards', 'subheading' => 'Community reinterpretations linked back to their original Nova Cards.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'context' => 'remixed', ]); } public function remixHighlights(Request $request): View { $cards = $this->publicCardsQuery() ->whereNotNull('original_card_id') ->orderByDesc('remixes_count') ->orderByDesc('saves_count') ->orderByDesc('likes_count') ->orderByDesc('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => 'Best Remixes - Skinbase Nova', 'description' => 'Browse standout Nova Card remixes ranked by remix traction, saves, and likes.', 'canonical' => route('cards.remix-highlights'), 'robots' => 'index,follow', ], 'heading' => 'Best remixes', 'subheading' => 'The strongest community reinterpretations ranked by remix traction and engagement.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'context' => 'remix-highlights', ]); } public function editorial(Request $request): View { $cards = NovaCard::query() ->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard']) ->featuredEditorial() ->orderByDesc('featured_score') ->orderByDesc('featured') ->orderByDesc('published_at') ->paginate(18) ->withQueryString(); $collections = NovaCardCollection::query() ->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']) ->where(function ($query): void { $query->where('featured', true) ->orWhere('official', true); }) ->orderByDesc('featured') ->orderByDesc('official') ->orderByDesc('updated_at') ->limit(4) ->get(); $challenges = NovaCardChallenge::query() ->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED]) ->where(function ($query): void { $query->where('featured', true) ->orWhere('official', true); }) ->orderByDesc('featured') ->orderBy('starts_at') ->limit(4) ->get(); $featuredCreators = User::query() ->where('nova_featured_creator', true) ->whereHas('novaCards', fn ($query) => $query->publiclyVisible()) ->withCount([ 'novaCards as public_cards_count' => fn ($query) => $query->publiclyVisible(), 'novaCards as featured_cards_count' => fn ($query) => $query->publiclyVisible()->where('featured', true), ]) ->withSum([ 'novaCards as total_views_count' => fn ($query) => $query->publiclyVisible(), ], 'views_count') ->orderByDesc('featured_cards_count') ->orderByDesc('public_cards_count') ->orderBy('username') ->limit(6) ->get() ->map(fn (User $creator): array => [ 'id' => (int) $creator->id, 'username' => (string) $creator->username, 'display_name' => $creator->name ?: '@' . $creator->username, 'public_url' => route('cards.creator', ['username' => $creator->username]), 'public_cards_count' => (int) ($creator->public_cards_count ?? 0), 'featured_cards_count' => (int) ($creator->featured_cards_count ?? 0), 'total_views_count' => (int) ($creator->total_views_count ?? 0), ]) ->values() ->all(); return view('cards.index', [ 'meta' => [ 'title' => 'Editorial Picks - Nova Cards - Skinbase Nova', 'description' => 'Browse editorial Nova Cards picks, featured collections, and highlighted challenges.', 'canonical' => route('cards.editorial'), 'robots' => 'index,follow', ], 'heading' => 'Editorial picks', 'subheading' => 'Curated Nova Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'seasonalHubs' => collect(), 'featuredCreators' => $featuredCreators, 'landingCollections' => $collections->map(fn (NovaCardCollection $collection): array => $this->presenter->collection($collection, $request->user()))->values()->all(), 'landingChallenges' => $challenges, 'context' => 'editorial', ]); } public function seasonal(Request $request): View { $seasonalHubs = $this->seasonalHubs(); $cards = $this->cardsTaggedWithSlugs($seasonalHubs->pluck('tag_slugs')->flatten()->unique()->values()->all()) ->latest('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => 'Seasonal Cards - Nova Cards - Skinbase Nova', 'description' => 'Browse seasonal and event-aware Nova Cards grouped by recurring moods, holidays, and time-of-year themes.', 'canonical' => route('cards.seasonal'), 'robots' => 'index,follow', ], 'heading' => 'Seasonal cards', 'subheading' => 'Discover Nova Cards grouped by recurring seasonal and campaign-style themes.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'seasonalHubs' => $seasonalHubs, 'landingCollections' => [], 'landingChallenges' => collect(), 'context' => 'seasonal', ]); } public function challenges(Request $request): View { $challenges = NovaCardChallenge::query() ->with(['winnerCard.user']) ->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED]) ->orderByDesc('featured') ->orderBy('starts_at') ->get(); return view('cards.challenges', [ 'meta' => [ 'title' => 'Card Challenges - Skinbase Nova', 'description' => 'Browse active and completed Nova Cards challenges, prompts, and winners.', 'canonical' => route('cards.challenges'), 'robots' => 'index,follow', ], 'heading' => 'Card challenges', 'subheading' => 'Official prompts and community challenge runs for Nova Cards creators.', 'challenges' => $challenges, ]); } public function challenge(Request $request, string $slug): View { $challenge = NovaCardChallenge::query() ->with(['winnerCard.user', 'entries.card.user']) ->where('slug', $slug) ->firstOrFail(); $entries = $challenge->entries ->filter(fn ($entry): bool => $entry->card !== null && $entry->card->canBeViewedBy($request->user())) ->sortByDesc(fn ($entry) => $entry->created_at) ->values(); return view('cards.challenges', [ 'meta' => [ 'title' => $challenge->title . ' - Skinbase Nova', 'description' => $challenge->description ?: 'Browse entries for this Nova Cards challenge.', 'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]), 'robots' => 'index,follow', ], 'heading' => $challenge->title, 'subheading' => $challenge->description ?: 'Challenge entries and winner picks.', 'challenge' => $challenge, 'challengeEntryItems' => $entries->map(fn ($entry): array => [ 'id' => (int) $entry->id, 'status' => (string) $entry->status, 'note' => $entry->note, 'card' => $this->presenter->card($entry->card, false, $request->user()), ])->values()->all(), 'challenges' => collect([$challenge]), ]); } public function templates(Request $request): View { return view('cards.resources', [ 'meta' => [ 'title' => 'Template Packs - Skinbase Nova', 'description' => 'Browse official Nova Cards template packs and starting points.', 'canonical' => route('cards.templates'), 'robots' => 'index,follow', ], 'heading' => 'Template packs', 'subheading' => 'Official starter packs for editorial, story, and community remix workflows.', 'packs' => collect($this->presenter->options()['template_packs'] ?? []), 'templates' => collect($this->presenter->options()['templates'] ?? []), 'resourceType' => 'template', ]); } public function assets(Request $request): View { return view('cards.resources', [ 'meta' => [ 'title' => 'Asset Packs - Skinbase Nova', 'description' => 'Browse official Nova Cards asset packs for decorative and editorial layouts.', 'canonical' => route('cards.assets'), 'robots' => 'index,follow', ], 'heading' => 'Asset packs', 'subheading' => 'Official decorative and editorial pack sets for the Nova Cards v2 editor.', 'packs' => collect($this->presenter->options()['asset_packs'] ?? []), 'templates' => collect(), 'resourceType' => 'asset', ]); } public function tag(Request $request, string $tagSlug): View { $tag = NovaCardTag::query()->where('slug', $tagSlug)->firstOrFail(); $cards = $this->publicCardsQuery()->whereHas('tags', fn ($query) => $query->where('nova_card_tags.id', $tag->id))->latest('published_at')->paginate(18)->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => '#' . $tag->name . ' Cards - Skinbase Nova', 'description' => 'Browse Nova Cards tagged with #' . $tag->name . ' on Skinbase Nova.', 'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]), 'robots' => 'index,follow', ], 'heading' => '#' . $tag->name, 'subheading' => 'Browse cards using this tag.', 'cards' => $this->presenter->cards($cards->items()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect([$tag]), 'moodFamilies' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'seasonalHubs' => collect(), 'context' => 'tag', ]); } public function mood(Request $request, string $moodSlug): View { $mood = $this->moodFamilies()->firstWhere('key', $moodSlug); abort_unless($mood !== null, 404); $cards = $this->cardsTaggedWithSlugs((array) ($mood['tag_slugs'] ?? [])) ->latest('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => $mood['label'] . ' Mood Cards - Skinbase Nova', 'description' => 'Browse Nova Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase Nova.', 'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]), 'robots' => 'index,follow', ], 'heading' => $mood['label'], 'subheading' => 'Discover Nova Cards grouped by a curated mood family using durable tag mappings.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => $this->moodFamilies(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'seasonalHubs' => collect(), 'context' => 'mood', ]); } public function style(Request $request, string $styleSlug): View { $style = $this->styleFamilies()->firstWhere('key', $styleSlug); abort_unless($style !== null, 404); $cards = $this->publicCardsQuery() ->where('style_family', $style['key']) ->latest('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => $style['label'] . ' Style Cards - Skinbase Nova', 'description' => 'Browse Nova Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase Nova.', 'canonical' => route('cards.style', ['styleSlug' => $style['key']]), 'robots' => 'index,follow', ], 'heading' => $style['label'], 'subheading' => 'Discover Nova Cards grouped by a shared visual style family.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => collect(), 'styleFamilies' => $this->styleFamilies(), 'paletteFamilies' => $this->paletteFamilies(), 'seasonalHubs' => collect(), 'context' => 'style', ]); } public function palette(Request $request, string $paletteSlug): View { $palette = $this->paletteFamilies()->firstWhere('key', $paletteSlug); abort_unless($palette !== null, 404); $cards = $this->publicCardsQuery() ->where('palette_family', $palette['key']) ->latest('published_at') ->paginate(18) ->withQueryString(); return view('cards.index', [ 'meta' => [ 'title' => $palette['label'] . ' Palette Cards - Skinbase Nova', 'description' => 'Browse Nova Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase Nova.', 'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]), 'robots' => 'index,follow', ], 'heading' => $palette['label'], 'subheading' => 'Discover Nova Cards grouped by shared palette families and color direction.', 'cards' => $this->presenter->cards($cards->items(), false, $request->user()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => collect(), 'styleFamilies' => $this->styleFamilies(), 'paletteFamilies' => $this->paletteFamilies(), 'seasonalHubs' => collect(), 'context' => 'palette', ]); } public function creator(Request $request, string $username): View|RedirectResponse { $normalized = UsernamePolicy::normalize($username); $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); if ($username !== strtolower((string) $user->username)) { return redirect()->route('cards.creator', ['username' => strtolower((string) $user->username)], 301); } return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [ 'meta' => [ 'title' => '@' . $user->username . ' Cards - Skinbase Nova', 'description' => 'Browse Nova Cards created by @' . $user->username . ' on Skinbase Nova.', 'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]), 'robots' => 'index,follow', ], 'heading' => '@' . $user->username, 'subheading' => 'Cards created by ' . ($user->name ?: '@' . $user->username), 'context' => 'creator', ])); } public function creatorPortfolio(Request $request, string $username): View|RedirectResponse { $normalized = UsernamePolicy::normalize($username); $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); if ($username !== strtolower((string) $user->username)) { return redirect()->route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)], 301); } return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [ 'meta' => [ 'title' => '@' . $user->username . ' Portfolio - Skinbase Nova', 'description' => 'Browse the dedicated Nova Cards portfolio page for @' . $user->username . ' on Skinbase Nova.', 'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]), 'robots' => 'index,follow', ], 'heading' => '@' . $user->username . ' Portfolio', 'subheading' => 'A dedicated Nova Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.', 'context' => 'creator-portfolio', ])); } private function creatorPagePayload(Request $request, User $user): array { $creatorCards = $this->publicCardsQuery()->where('user_id', $user->id); $cards = (clone $creatorCards)->latest('published_at')->paginate(18)->withQueryString(); $creatorSummary = $this->buildCreatorSummary($user); $creatorFeaturedWorks = $this->presenter->cards($this->creatorFeaturedWorks($user)->all(), false, $request->user()); $creatorFeaturedCollections = NovaCardCollection::query() ->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']) ->where('user_id', $user->id) ->where('featured', true) ->where(function ($query): void { $query->where('official', true) ->orWhere('visibility', NovaCardCollection::VISIBILITY_PUBLIC); }) ->orderByDesc('official') ->orderByDesc('updated_at') ->limit(3) ->get() ->map(fn (NovaCardCollection $collection): array => $this->presenter->collection($collection, $request->user())) ->values() ->all(); $creatorHighlights = $this->presenter->cards( $this->creatorHighlights($user, $creatorFeaturedWorks !== [])->all(), false, $request->user() ); $creatorMostRemixedWorks = $this->presenter->cards( $this->creatorMostRemixedWorks($user)->all(), false, $request->user() ); $creatorMostLikedWorks = $this->presenter->cards( $this->creatorMostLikedWorks($user)->all(), false, $request->user() ); $creatorRemixActivity = $this->creatorRemixActivity($user, $request->user()); $creatorRemixGraph = $this->creatorRemixGraph($user); $creatorPreferenceSignals = $this->creatorPreferenceSignals($user); $creatorTimeline = $this->creatorTimeline($user, $request->user()); $creatorChallengeHistory = $this->creatorChallengeHistory($user); return [ 'cards' => $this->presenter->cards($cards->items()), 'pagination' => $cards, 'featuredCards' => [], 'trendingCards' => [], 'categories' => collect(), 'tags' => collect(), 'moodFamilies' => collect(), 'styleFamilies' => collect(), 'paletteFamilies' => collect(), 'seasonalHubs' => collect(), 'creatorSummary' => $creatorSummary, 'creatorFeaturedWorks' => $creatorFeaturedWorks, 'creatorFeaturedCollections' => $creatorFeaturedCollections, 'creatorHighlights' => $creatorHighlights, 'creatorMostRemixedWorks' => $creatorMostRemixedWorks, 'creatorMostLikedWorks' => $creatorMostLikedWorks, 'creatorRemixActivity' => $creatorRemixActivity, 'creatorRemixGraph' => $creatorRemixGraph, 'creatorPreferenceSignals' => $creatorPreferenceSignals, 'creatorTimeline' => $creatorTimeline, 'creatorChallengeHistory' => $creatorChallengeHistory, ]; } public function collection(Request $request, string $slug, int $id): View|RedirectResponse { $collection = NovaCardCollection::query() ->with(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']) ->findOrFail($id); abort_unless($collection->isPubliclyVisible(), 404); if ($slug !== $collection->slug) { return redirect()->route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id], 301); } return view('cards.collection', [ 'meta' => [ 'title' => $collection->name . ' - Nova Cards Collection - Skinbase Nova', 'description' => $collection->description ?: 'Browse this curated Nova Cards collection.', 'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]), 'robots' => 'index,follow', ], 'collection' => $this->presenter->collection($collection, $request->user(), true), ]); } public function lineage(Request $request, string $slug, int $id): View|RedirectResponse { $card = NovaCard::query() ->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard.user', 'rootCard.user']) ->published() ->findOrFail($id); abort_unless($card->canBeViewedBy($request->user()), 404); if ($slug !== $card->slug) { return redirect()->route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id], 301); } $lineage = $this->lineage->resolve($card, $request->user()); return view('cards.lineage', [ 'meta' => [ 'title' => $card->title . ' Lineage - Nova Cards - Skinbase Nova', 'description' => 'Browse the remix lineage and related variants for this Nova Card.', 'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]), 'robots' => 'index,follow', ], 'card' => $lineage['card'], 'trail' => $lineage['trail'], 'rootCard' => $lineage['root_card'], 'familyCards' => $lineage['family_cards'], ]); } public function show(Request $request, string $slug, int $id): View|RedirectResponse { $card = NovaCard::query() ->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags']) ->published() ->findOrFail($id); if (! $card->canBeViewedBy($request->user())) { abort(404); } if ($slug !== $card->slug) { return redirect()->route('cards.show', ['slug' => $card->slug, 'id' => $card->id], 301); } $card->increment('views_count'); $card->refresh(); UpdateNovaCardStatsJob::dispatch($card->id); event(new NovaCardViewed($card, $request->user()?->id)); $relatedByCategory = $card->category_id ? $this->publicCardsQuery()->where('category_id', $card->category_id)->where('id', '!=', $card->id)->limit(6)->get() : collect(); $relatedByTags = $card->tags->isNotEmpty() ? $this->publicCardsQuery()->where('id', '!=', $card->id)->whereHas('tags', fn ($query) => $query->whereIn('nova_card_tags.id', $card->tags->pluck('id')))->limit(6)->get() : collect(); $moreFromCreator = $this->publicCardsQuery()->where('user_id', $card->user_id)->where('id', '!=', $card->id)->limit(6)->get(); // v3: smart related cards using relatedness service. $smartRelated = $this->relatedService->related($card, 8); return view('cards.show', [ 'card' => $this->presenter->card($card, true, $request->user()), 'meta' => [ 'title' => $card->title . ' - Nova Cards - Skinbase Nova', 'description' => $card->description ?: $card->quote_text, 'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]), 'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow', ], 'relatedByCategory' => $this->presenter->cards($relatedByCategory), 'relatedByTags' => $this->presenter->cards($relatedByTags), 'moreFromCreator' => $this->presenter->cards($moreFromCreator), 'smartRelated' => $this->presenter->cards($smartRelated->all()), 'challengeEntries' => $card->challengeEntries() ->with('challenge') ->whereNotIn('status', [\App\Models\NovaCardChallengeEntry::STATUS_HIDDEN, \App\Models\NovaCardChallengeEntry::STATUS_REJECTED]) ->latest() ->limit(4) ->get(), 'comments' => $this->comments->mapComments($card, $request->user()), ]); } private function publicCardsQuery() { return NovaCard::query() ->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard']) ->publiclyVisible(); } private function buildCreatorSummary(User $user): array { $aggregate = $this->publicCardsQuery() ->where('user_id', $user->id) ->selectRaw('COUNT(*) as total_cards') ->selectRaw('SUM(CASE WHEN featured = 1 THEN 1 ELSE 0 END) as total_featured_cards') ->selectRaw('COALESCE(SUM(views_count), 0) as total_views') ->selectRaw('COALESCE(SUM(likes_count), 0) as total_likes') ->selectRaw('COALESCE(SUM(saves_count), 0) as total_saves') ->selectRaw('COALESCE(SUM(remixes_count), 0) as total_remixes') ->selectRaw('COALESCE(SUM(challenge_entries_count), 0) as total_challenge_entries') ->first(); $topStyles = $this->publicCardsQuery() ->where('user_id', $user->id) ->whereNotNull('style_family') ->select('style_family') ->selectRaw('COUNT(*) as cards_count') ->groupBy('style_family') ->orderByDesc('cards_count') ->limit(4) ->get() ->map(fn (NovaCard $card): array => [ 'key' => (string) $card->style_family, 'label' => str($card->style_family)->replace('-', ' ')->title()->toString(), 'cards_count' => (int) $card->cards_count, ]) ->values() ->all(); $topCategories = NovaCardCategory::query() ->whereHas('cards', fn ($query) => $query->publiclyVisible()->where('user_id', $user->id)) ->withCount(['cards as creator_cards_count' => fn ($query) => $query->publiclyVisible()->where('user_id', $user->id)]) ->orderByDesc('creator_cards_count') ->orderBy('name') ->limit(4) ->get() ->map(fn (NovaCardCategory $category): array => [ 'slug' => (string) $category->slug, 'name' => (string) $category->name, 'cards_count' => (int) $category->creator_cards_count, ]) ->values() ->all(); $tagCounts = NovaCardTag::query() ->select('nova_card_tags.id', 'nova_card_tags.slug', 'nova_card_tags.name') ->join('nova_card_tag_relation', 'nova_card_tag_relation.tag_id', '=', 'nova_card_tags.id') ->join('nova_cards', 'nova_cards.id', '=', 'nova_card_tag_relation.card_id') ->where('nova_cards.user_id', $user->id) ->where('nova_cards.status', NovaCard::STATUS_PUBLISHED) ->where('nova_cards.visibility', NovaCard::VISIBILITY_PUBLIC) ->whereNotIn('nova_cards.moderation_status', [NovaCard::MOD_FLAGGED, NovaCard::MOD_REJECTED]) ->selectRaw('COUNT(*) as cards_count') ->groupBy('nova_card_tags.id', 'nova_card_tags.slug', 'nova_card_tags.name') ->orderByDesc(DB::raw('COUNT(*)')) ->orderBy('nova_card_tags.name') ->get(); $topTags = $tagCounts ->take(6) ->map(fn (NovaCardTag $tag): array => [ 'slug' => (string) $tag->slug, 'name' => (string) $tag->name, 'cards_count' => (int) $tag->cards_count, ]) ->values() ->all(); $topPalettes = $this->publicCardsQuery() ->where('user_id', $user->id) ->whereNotNull('palette_family') ->select('palette_family') ->selectRaw('COUNT(*) as cards_count') ->groupBy('palette_family') ->orderByDesc('cards_count') ->limit(4) ->get() ->map(fn (NovaCard $card): array => [ 'key' => (string) $card->palette_family, 'label' => str($card->palette_family)->replace('-', ' ')->title()->toString(), 'cards_count' => (int) $card->cards_count, ]) ->values() ->all(); $topMoods = $this->moodFamilies() ->map(function (array $mood) use ($tagCounts): array { $count = $tagCounts ->filter(fn (NovaCardTag $tag): bool => in_array((string) $tag->slug, $mood['tag_slugs'], true)) ->sum(fn (NovaCardTag $tag): int => (int) $tag->cards_count); return [ 'key' => $mood['key'], 'label' => $mood['label'], 'cards_count' => $count, ]; }) ->filter(fn (array $mood): bool => (int) $mood['cards_count'] > 0) ->sortByDesc('cards_count') ->take(4) ->values() ->all(); return [ 'creator' => [ 'username' => (string) $user->username, 'name' => $user->name, 'display_name' => $user->name ?: '@' . $user->username, ], 'stats' => [ 'total_cards' => (int) ($aggregate->total_cards ?? 0), 'total_featured_cards' => (int) ($aggregate->total_featured_cards ?? 0), 'total_views' => (int) ($aggregate->total_views ?? 0), 'total_likes' => (int) ($aggregate->total_likes ?? 0), 'total_saves' => (int) ($aggregate->total_saves ?? 0), 'total_remixes' => (int) ($aggregate->total_remixes ?? 0), 'total_challenge_entries' => (int) ($aggregate->total_challenge_entries ?? 0), ], 'top_styles' => $topStyles, 'top_palettes' => $topPalettes, 'top_moods' => $topMoods, 'top_categories' => $topCategories, 'top_tags' => $topTags, ]; } private function creatorFeaturedWorks(User $user) { return $this->publicCardsQuery() ->where('user_id', $user->id) ->where('featured', true) ->orderByDesc('featured_score') ->orderByDesc('published_at') ->limit(4) ->get(); } private function creatorHighlights(User $user, bool $excludeFeatured = false) { $query = $this->publicCardsQuery() ->where('user_id', $user->id); if ($excludeFeatured) { $query->where('featured', false); } $query ->orderByDesc('featured') ->orderByDesc('featured_score') ->orderByDesc('saves_count') ->orderByDesc('likes_count') ->orderByDesc('views_count') ->limit(4); return $query->get(); } private function creatorMostRemixedWorks(User $user) { return $this->publicCardsQuery() ->where('user_id', $user->id) ->where('remixes_count', '>', 0) ->orderByDesc('remixes_count') ->orderByDesc('saves_count') ->orderByDesc('likes_count') ->orderByDesc('published_at') ->limit(4) ->get(); } private function creatorMostLikedWorks(User $user) { return $this->publicCardsQuery() ->where('user_id', $user->id) ->where(function ($query): void { $query->where('likes_count', '>', 0) ->orWhere('saves_count', '>', 0); }) ->orderByDesc('likes_count') ->orderByDesc('saves_count') ->orderByDesc('views_count') ->orderByDesc('published_at') ->limit(4) ->get(); } private function creatorRemixActivity(User $user, ?User $viewer = null): array { $cards = $this->publicCardsQuery() ->where('user_id', $user->id) ->where(function ($query): void { $query->where('remixes_count', '>', 0) ->orWhereNotNull('original_card_id'); }) ->orderByDesc('remixes_count') ->orderByDesc('published_at') ->limit(6) ->get(); $branchCards = $cards ->map(function (NovaCard $card) use ($viewer): array { $presented = $this->presenter->card($card, false, $viewer); $branchType = $card->original_card_id ? 'Published remix' : 'Community branch'; $sourceLabel = $card->originalCard?->title ?? $card->rootCard?->title ?? $card->title; return [ 'card' => $presented, 'branch_type' => $branchType, 'source_label' => $sourceLabel, 'lineage_url' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]), ]; }) ->values() ->all(); return [ 'total_cards_remixed_by_community' => $cards->whereNull('original_card_id')->filter(fn (NovaCard $card): bool => (int) $card->remixes_count > 0)->count(), 'total_published_remixes' => $cards->whereNotNull('original_card_id')->count(), 'branches' => $branchCards, ]; } private function creatorRemixGraph(User $user): array { $cards = $this->publicCardsQuery() ->where('user_id', $user->id) ->where(function ($query): void { $query->where('remixes_count', '>', 0) ->orWhereNotNull('original_card_id') ->orWhereNotNull('root_card_id'); }) ->get(); $branches = $cards ->groupBy(fn (NovaCard $card): string => (string) ($card->root_card_id ?: $card->id)) ->map(function ($group): array { /** @var NovaCard $root */ $root = $group->firstWhere('id', $group->first()->root_card_id ?: $group->first()->id) ?? $group->first(); $peak = $group->sortByDesc('remixes_count')->first(); return [ 'root_title' => (string) $root->title, 'cards_count' => $group->count(), 'total_remixes' => (int) $group->sum('remixes_count'), 'peak_title' => (string) ($peak?->title ?? $root->title), ]; }) ->sortByDesc('total_remixes') ->take(4) ->values(); $maxRemixes = max(1, (int) $branches->max('total_remixes')); return $branches ->map(fn (array $branch): array => [ ...$branch, 'width_percent' => (int) max(16, round(($branch['total_remixes'] / $maxRemixes) * 100)), ]) ->all(); } private function creatorPreferenceSignals(User $user): array { $topFormats = $this->publicCardsQuery() ->where('user_id', $user->id) ->select('format') ->selectRaw('COUNT(*) as cards_count') ->groupBy('format') ->orderByDesc('cards_count') ->limit(3) ->get() ->map(fn (NovaCard $card): array => [ 'key' => (string) $card->format, 'label' => str($card->format)->replace('-', ' ')->title()->toString(), 'cards_count' => (int) $card->cards_count, ]) ->values() ->all(); $topTemplates = NovaCardTemplate::query() ->whereHas('cards', fn ($query) => $query->publiclyVisible()->where('user_id', $user->id)) ->withCount(['cards as creator_cards_count' => fn ($query) => $query->publiclyVisible()->where('user_id', $user->id)]) ->orderByDesc('creator_cards_count') ->orderBy('name') ->limit(3) ->get() ->map(fn (NovaCardTemplate $template): array => [ 'name' => (string) $template->name, 'cards_count' => (int) $template->creator_cards_count, ]) ->values() ->all(); $editorModes = $this->publicCardsQuery() ->where('user_id', $user->id) ->whereNotNull('editor_mode_last_used') ->select('editor_mode_last_used') ->selectRaw('COUNT(*) as cards_count') ->groupBy('editor_mode_last_used') ->orderByDesc('cards_count') ->get(); $presetCounts = NovaCardCreatorPreset::query() ->where('user_id', $user->id) ->select('preset_type') ->selectRaw('COUNT(*) as presets_count') ->groupBy('preset_type') ->orderByDesc('presets_count') ->get() ->map(fn (NovaCardCreatorPreset $preset): array => [ 'type' => (string) $preset->preset_type, 'label' => str($preset->preset_type)->replace('-', ' ')->title()->toString(), 'presets_count' => (int) $preset->presets_count, ]) ->values() ->all(); return [ 'top_formats' => $topFormats, 'top_templates' => $topTemplates, 'preferred_editor_mode' => $editorModes->isNotEmpty() ? [ 'key' => (string) $editorModes->first()->editor_mode_last_used, 'label' => (string) str((string) $editorModes->first()->editor_mode_last_used)->replace('-', ' ')->title(), 'cards_count' => (int) $editorModes->first()->cards_count, ] : null, 'preset_counts' => $presetCounts, ]; } private function creatorTimeline(User $user, ?User $viewer = null): array { return $this->publicCardsQuery() ->where('user_id', $user->id) ->latest('published_at') ->latest('id') ->limit(6) ->get() ->map(function (NovaCard $card) use ($viewer): array { $presented = $this->presenter->card($card, false, $viewer); $signals = []; if ($card->featured) { $signals[] = 'Featured release'; } if ((int) $card->remixes_count > 0) { $signals[] = 'Remix traction'; } if ((int) $card->likes_count > 0 || (int) $card->saves_count > 0) { $signals[] = 'Audience favorite'; } return [ 'card' => $presented, 'signals' => $signals, ]; }) ->values() ->all(); } private function creatorChallengeHistory(User $user): array { return NovaCardChallengeEntry::query() ->with(['challenge', 'card.user']) ->where('user_id', $user->id) ->whereNotIn('status', [NovaCardChallengeEntry::STATUS_HIDDEN, NovaCardChallengeEntry::STATUS_REJECTED]) ->whereHas('card', fn ($query) => $query->publiclyVisible()->where('user_id', $user->id)) ->whereHas('challenge') ->orderByRaw("CASE status WHEN 'winner' THEN 0 WHEN 'featured' THEN 1 WHEN 'submitted' THEN 2 WHEN 'active' THEN 3 ELSE 4 END") ->latest('id') ->limit(4) ->get() ->map(fn (NovaCardChallengeEntry $entry): array => [ 'status_label' => match ((string) $entry->status) { NovaCardChallengeEntry::STATUS_WINNER => 'Winner entry', NovaCardChallengeEntry::STATUS_FEATURED => 'Featured entry', NovaCardChallengeEntry::STATUS_ACTIVE => 'Active entry', default => 'Submitted entry', }, 'challenge_title' => (string) ($entry->challenge?->title ?? 'Challenge'), 'challenge_url' => $entry->challenge ? route('cards.challenges.show', ['slug' => $entry->challenge->slug]) : null, 'challenge_status' => (string) ($entry->challenge?->status ?? ''), 'card_title' => (string) ($entry->card?->title ?? 'Card'), 'card_url' => $entry->card ? $entry->card->publicUrl() : null, 'official' => (bool) ($entry->challenge?->official ?? false), ]) ->values() ->all(); } private function cardsTaggedWithSlugs(array $tagSlugs) { $tagSlugs = collect($tagSlugs)->filter()->unique()->values()->all(); return $this->publicCardsQuery()->whereHas('tags', fn ($query) => $query->whereIn('nova_card_tags.slug', $tagSlugs)); } private function moodFamilies() { return collect((array) config('nova_cards.mood_families', [])) ->map(fn (array $mood): array => [ 'key' => (string) ($mood['key'] ?? ''), 'label' => (string) ($mood['label'] ?? str((string) ($mood['key'] ?? ''))->replace('-', ' ')->title()), 'tag_slugs' => array_values((array) ($mood['tag_slugs'] ?? [])), ]) ->filter(fn (array $mood): bool => $mood['key'] !== '') ->values(); } private function styleFamilies() { return collect((array) config('nova_cards.style_families', [])) ->map(fn (array $style): array => [ 'key' => (string) ($style['key'] ?? ''), 'label' => (string) ($style['label'] ?? str((string) ($style['key'] ?? ''))->replace('-', ' ')->title()), ]) ->filter(fn (array $style): bool => $style['key'] !== '') ->values(); } private function paletteFamilies() { return collect((array) config('nova_cards.palette_families', [])) ->map(fn (array $palette): array => [ 'key' => (string) ($palette['key'] ?? ''), 'label' => (string) ($palette['label'] ?? str((string) ($palette['key'] ?? ''))->replace('-', ' ')->title()), ]) ->filter(fn (array $palette): bool => $palette['key'] !== '') ->values(); } private function seasonalHubs() { return collect((array) config('nova_cards.seasonal_hubs', [])) ->map(fn (array $hub): array => [ 'key' => (string) ($hub['key'] ?? ''), 'label' => (string) ($hub['label'] ?? str((string) ($hub['key'] ?? ''))->replace('-', ' ')->title()), 'tag_slugs' => array_values((array) ($hub['tag_slugs'] ?? [])), ]) ->filter(fn (array $hub): bool => $hub['key'] !== '') ->values(); } }