Files
SkinbaseNova/app/Http/Controllers/Web/ArtworkPageController.php
T
klevze 6b00652a20 Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00

356 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\ArtworkNavigationService;
use App\Services\ArtworkRelatedService;
use App\Services\ErrorSuggestionService;
use App\Services\GroupService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\ThumbnailPresenter;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
final class ArtworkPageController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly ArtworkMaturityService $maturity,
private readonly ArtworkNavigationService $navigation,
private readonly ArtworkRelatedService $relatedArtworks,
) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
{
// ── Step 1: check existence including soft-deleted ─────────────────
$raw = Artwork::withTrashed()->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 = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
$md = 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: hydrate the already-validated model ────────────────────
// The initial withTrashed lookup is intentionally retained so that
// missing, deleted, private, and unpublished artwork keep their
// distinct responses. Reusing that model avoids a second identical
// primary-key lookup on the public path.
$artwork = $raw;
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'primaryCategory.contentType', 'primaryCategory.parent.contentType', 'tags', 'stats', 'awardStat']);
$this->loadCategoryAncestors(
$artwork->categories
->concat(collect([$artwork->resolvedPrimaryCategory()]))
->filter()
->unique('id')
->values()
);
$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);
}
$artworkData = (new ArtworkResource($artwork))->toArray($request);
$navigationData = $this->navigation->navigationFor($artwork);
// ArtworkResource is the canonical thumbnail projection. Reuse its
// exact values for page props and SEO instead of presenting each
// variant a second time in the controller.
$thumbMd = $artworkData['thumbs']['md'] ?? [];
$thumbLg = $artworkData['thumbs']['lg'] ?? [];
$thumbXl = $artworkData['thumbs']['xl'] ?? [];
$thumbSq = $artworkData['thumbs']['sq'] ?? [];
$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();
$related = $this->relatedArtworks->related($artwork, $request->user(), 12)
->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();
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
$viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
return Inertia::render('ArtworkPage', [
'artwork' => $artworkData,
'navigation' => $navigationData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'related' => $related,
'canonicalUrl' => $canonical,
'groupSummary' => $groupSummary,
'isAuthenticated' => $viewerId !== null,
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
'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 = 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<int, array{name: string, url: string}>
*/
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
{
$primaryCategory = $artwork->resolvedPrimaryCategory();
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();
}
}
}