where('id', $id)->first(); if (! $raw) { // Artwork never existed → contextual 404 $suggestions = app(ErrorSuggestionService::class); return response(view('errors.contextual.artwork-not-found', [ 'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()), ]), 404); } if ($raw->trashed()) { // Artwork permanently deleted → 410 Gone return response(view('errors.410'), 410); } if (! $raw->is_public || ! $raw->is_approved || (string) ($raw->visibility ?? '') === Artwork::VISIBILITY_PRIVATE || $raw->published_at === null || $raw->published_at->isFuture()) { // Artwork exists but is private/unapproved → 403 Forbidden. // Show other public artworks by the same creator as recovery suggestions. $suggestions = app(ErrorSuggestionService::class); $creatorArtworks = collect(); $creatorUsername = null; if ($raw->user_id) { $raw->loadMissing('user'); $creatorUsername = $raw->user?->username; $creatorArtworks = $this->safeSuggestions(function () use ($raw) { return Artwork::query() ->with('user') ->where('user_id', $raw->user_id) ->where('id', '!=', $raw->id) ->catalogVisible() ->limit(6) ->get() ->map(function (Artwork $a) { $slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id; $md = \App\Services\ThumbnailPresenter::present($a, 'md'); return [ 'id' => $a->id, 'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]), 'thumb' => $md['url'] ?? null, ]; }); }); } return response(view('errors.contextual.artwork-not-found', [ 'message' => 'This artwork is not publicly available.', 'isForbidden' => true, 'creatorArtworks' => $creatorArtworks, 'creatorUsername' => $creatorUsername, 'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()), ]), 403); } // ── Step 2: full load with all relations ─────────────────────────── $artwork = Artwork::with(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']) ->where('id', $id) ->public() ->published() ->firstOrFail(); $this->loadCategoryAncestors($artwork->categories); $canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title)); if ($canonicalSlug === '') { $canonicalSlug = (string) $artwork->id; } if ((string) $slug !== $canonicalSlug) { return redirect()->route('art.show', [ 'id' => $artwork->id, 'slug' => $canonicalSlug, ], 301); } $thumbMd = ThumbnailPresenter::present($artwork, 'md'); $thumbLg = ThumbnailPresenter::present($artwork, 'lg'); $thumbXl = ThumbnailPresenter::present($artwork, 'xl'); $thumbSq = ThumbnailPresenter::present($artwork, 'sq'); $artworkData = (new ArtworkResource($artwork))->toArray($request); $groupSummary = null; if ($artwork->group) { $artwork->group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']); $groupSummary = $this->groups->mapGroupCard($artwork->group, $request->user()); } $canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]); $authorName = $artwork->group?->name ?: $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist'; $description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…'); $meta = [ 'title' => sprintf('%s by %s — Skinbase', html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), html_entity_decode((string) $authorName, ENT_QUOTES | ENT_HTML5, 'UTF-8')), 'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'canonical' => $canonical, 'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null, 'og_width' => $thumbXl['width'] ?? $thumbLg['width'] ?? null, 'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null, ]; $seo = app(SeoFactory::class)->artwork($artwork, [ 'md' => $thumbMd, 'lg' => $thumbLg, 'xl' => $thumbXl, ], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray(); $categoryIds = $artwork->categories->pluck('id')->filter()->values(); $tagIds = $artwork->tags->pluck('id')->filter()->values(); $related = Artwork::query() ->with(['user', 'group', 'categories.contentType']) ->whereKeyNot($artwork->id) ->public() ->published() ->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user())) ->where(function ($query) use ($artwork, $categoryIds, $tagIds): void { $query->where('user_id', $artwork->user_id); if ($artwork->group_id) { $query->orWhere('group_id', $artwork->group_id); } if ($categoryIds->isNotEmpty()) { $query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void { $categoryQuery->whereIn('categories.id', $categoryIds->all()); }); } if ($tagIds->isNotEmpty()) { $query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void { $tagQuery->whereIn('tags.id', $tagIds->all()); }); } }) ->latest('published_at') ->limit(12) ->get() ->map(function (Artwork $item): array { $itemSlug = Str::slug((string) ($item->slug ?: $item->title)); if ($itemSlug === '') { $itemSlug = (string) $item->id; } $sm = ThumbnailPresenter::present($item, 'sm'); $md = ThumbnailPresenter::present($item, 'md'); return $this->maturity->decoratePayload([ 'id' => (int) $item->id, 'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'author_id' => (int) ($item->user?->id ?? 0), 'publisher_type' => $item->group ? 'group' : 'user', 'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0), 'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]), 'thumb' => $sm['url'] ?? null, 'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w', ], $item, request()->user()); }) ->values() ->all(); $approvedComments = ArtworkComment::query() ->with('user.profile') ->where('artwork_id', $artwork->id) ->where('is_approved', true) ->orderBy('created_at') ->limit(500) ->get(); $commentsByParent = $approvedComments->groupBy( static fn (ArtworkComment $comment): string => $comment->parent_id === null ? 'root' : (string) $comment->parent_id ); // Recursive helper to format a comment and its nested replies. $formatComment = null; $formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array { /** @var Collection $replies */ $replies = $commentsByParent->get((string) $c->id, collect()); $user = $c->user; $userId = (int) ($c->user_id ?? 0); $avatarHash = $user?->profile?->avatar_hash ?? null; $canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie'; $rawContent = (string) ($c->raw_content ?? $c->content ?? ''); $renderedContent = $c->rendered_content; if (! is_string($renderedContent) || trim($renderedContent) === '') { $renderedContent = $rawContent !== '' ? ContentSanitizer::render($rawContent) : nl2br(e(strip_tags((string) ($c->content ?? '')))); } return [ 'id' => $c->id, 'parent_id' => $c->parent_id, 'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'raw_content' => $c->raw_content ?? $c->content, 'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks), 'created_at' => $c->created_at?->toIso8601String(), 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'user' => [ 'id' => $userId, 'name' => $user?->name, 'username' => $user?->username, 'display' => $user?->username ?? $user?->name ?? 'User', 'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null), 'avatar_url' => $avatarHash !== null ? AvatarUrl::forUser($userId, $avatarHash, 64) : AvatarUrl::default(), 'level' => (int) ($user?->level ?? 1), 'rank' => (string) ($user?->rank ?? 'Newbie'), ], 'replies' => $replies->map($formatComment)->values()->all(), ]; }; $comments = $commentsByParent ->get('root', collect()) ->map($formatComment) ->values() ->all(); $canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped'); $userId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null; return Inertia::render('ArtworkPage', [ 'artwork' => $artworkData, 'presentMd' => $thumbMd, 'presentLg' => $thumbLg, 'presentXl' => $thumbXl, 'presentSq' => $thumbSq, 'related' => $related, 'canonicalUrl' => $canonical, 'comments' => $comments, 'groupSummary' => $groupSummary, 'isAuthenticated' => $userId !== null, 'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $userId), 'seo' => $seo, ])->rootView('artworks.show'); } /** * Build per-slug reaction totals for the given artwork, including * whether the given user has each reaction (mine=true). * * Mirrors ReactionController::getTotals() so the page can render * the correct state without a separate client-side fetch on first load. */ private function artworkReactionTotals(int $artworkId, ?int $userId): array { $rows = DB::table('artwork_reactions') ->where('artwork_id', $artworkId) ->selectRaw('reaction, COUNT(*) as total') ->groupBy('reaction') ->get() ->keyBy('reaction'); $totals = []; foreach (ReactionType::cases() as $type) { $slug = $type->value; $count = (int) ($rows[$slug]->total ?? 0); $mine = false; if ($userId !== null && $count > 0) { $mine = DB::table('artwork_reactions') ->where('artwork_id', $artworkId) ->where('reaction', $slug) ->where('user_id', $userId) ->exists(); } $totals[$slug] = [ 'emoji' => $type->emoji(), 'label' => $type->label(), 'count' => $count, 'mine' => $mine, ]; } return $totals; } private function loadCategoryAncestors(Collection $categories): void { $currentLevel = $categories->filter(); while ($currentLevel->isNotEmpty()) { $fetchedParents = collect(); $missingParentIds = $currentLevel ->filter(static fn ($category) => $category->parent_id !== null && ! $category->relationLoaded('parent')) ->pluck('parent_id') ->filter() ->unique() ->values(); if ($missingParentIds->isNotEmpty()) { $fetchedParents = \App\Models\Category::query() ->with('contentType') ->whereIn('id', $missingParentIds->all()) ->get() ->keyBy('id'); $currentLevel->each(function ($category) use ($fetchedParents): void { if ($category->parent_id !== null && ! $category->relationLoaded('parent')) { $category->setRelation('parent', $fetchedParents->get($category->parent_id)); } }); } $currentLevel = $currentLevel ->map(static fn ($category) => $category->relationLoaded('parent') ? $category->getRelation('parent') : null) ->filter() ->unique('id') ->values(); } } /** * @return array */ private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array { $primaryCategory = $artwork->categories ->sortBy(fn ($category) => [ (int) ($category->sort_order ?? 0), (string) ($category->name ?? ''), ]) ->first(); if ($primaryCategory === null) { return [ ['name' => 'Explore', 'url' => url('/explore')], ['name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => $canonical], ]; } $contentType = $primaryCategory->contentType; $chain = collect(); $current = $primaryCategory; while ($current !== null) { $chain->prepend($current); $current = $current->relationLoaded('parent') ? $current->parent : null; } $breadcrumbs = []; $contentTypeSlug = trim((string) ($contentType?->slug ?? '')); $contentTypeName = trim((string) ($contentType?->name ?? '')); if ($contentTypeSlug !== '' && $contentTypeName !== '') { $breadcrumbs[] = [ 'name' => $contentTypeName, 'url' => url('/' . $contentTypeSlug), ]; } $pathSegments = []; foreach ($chain as $category) { $slug = trim((string) ($category->slug ?? '')); $name = trim((string) ($category->name ?? '')); if ($slug === '' || $name === '' || $contentTypeSlug === '') { continue; } $pathSegments[] = $slug; $breadcrumbs[] = [ 'name' => $name, 'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)), ]; } $breadcrumbs[] = [ 'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => $canonical, ]; return $breadcrumbs; } /** Silently catch suggestion query failures so error page never crashes. */ private function safeSuggestions(callable $fn): mixed { try { return $fn(); } catch (\Throwable) { return collect(); } } }