377 lines
16 KiB
PHP
377 lines
16 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\ArtworkComment;
|
|
use Illuminate\Support\Facades\DB;
|
|
use App\Services\ContentSanitizer;
|
|
use App\Services\ThumbnailPresenter;
|
|
use App\Services\ErrorSuggestionService;
|
|
use App\Services\GroupService;
|
|
use App\Services\Maturity\ArtworkMaturityService;
|
|
use App\Support\Seo\SeoFactory;
|
|
use App\Support\AvatarUrl;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
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,
|
|
) {}
|
|
|
|
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 = \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)->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<int, ArtworkComment> $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();
|
|
}
|
|
}
|
|
|
|
/** Silently catch suggestion query failures so error page never crashes. */
|
|
private function safeSuggestions(callable $fn): mixed
|
|
{
|
|
try {
|
|
return $fn();
|
|
} catch (\Throwable) {
|
|
return collect();
|
|
}
|
|
}
|
|
}
|