feat: artwork page carousels, recommendations, avatars & fixes

- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
2026-02-28 14:05:39 +01:00
parent 80100c7651
commit eee7df1f8c
46 changed files with 2536 additions and 498 deletions

View File

@@ -37,14 +37,19 @@ class ArtworkCommentController extends Controller
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$comments = ArtworkComment::with(['user', 'user.profile'])
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([
'user', 'user.profile',
'approvedReplies',
])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId));
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
return response()->json([
'data' => $items,
@@ -66,10 +71,25 @@ class ArtworkCommentController extends Controller
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]);
$raw = $request->input('content');
$parentId = $request->input('parent_id');
// If replying, validate parent belongs to same artwork and is approved
if ($parentId) {
$parent = ArtworkComment::where('artwork_id', $artwork->id)
->where('is_approved', true)
->find($parentId);
if (! $parent) {
return response()->json([
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
], 422);
}
}
// Validate markdown-lite content
$errors = ContentSanitizer::validate($raw);
@@ -82,6 +102,7 @@ class ArtworkCommentController extends Controller
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
@@ -103,7 +124,7 @@ class ArtworkCommentController extends Controller
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -139,7 +160,7 @@ class ArtworkCommentController extends Controller
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)]);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
}
// ─────────────────────────────────────────────────────────────────────────
@@ -162,14 +183,15 @@ class ArtworkCommentController extends Controller
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId): array
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
$data = [
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
@@ -184,5 +206,15 @@ class ArtworkCommentController extends Controller
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
];
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} else {
$data['replies'] = [];
}
return $data;
}
}

View File

@@ -106,6 +106,7 @@ class RankController extends Controller
$keyed = Artwork::whereIn('id', $ids)
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'categories' => function ($q): void {
$q->select(
'categories.id',

View File

@@ -88,7 +88,7 @@ final class SimilarArtworksController extends Controller
->paginate(200, 'page', 1);
$collection = $results->getCollection();
$collection->load(['tags:id,slug', 'stats']);
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
// ── PHP reranking ──────────────────────────────────────────────────────
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
@@ -146,6 +146,8 @@ final class SimilarArtworksController extends Controller
'slug' => $item['artwork']->slug,
'thumb' => $item['artwork']->thumbUrl('md'),
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
'author' => $item['artwork']->user?->name ?? 'Artist',
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
'author_id' => $item['artwork']->user_id,
'orientation' => $this->orientation($item['artwork']),
'width' => $item['artwork']->width,

View File

@@ -125,8 +125,8 @@ class ArtworkController extends Controller
return [
'id' => (int) $item->id,
'title' => (string) $item->title,
'author' => (string) optional($item->user)->name,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
'url' => route('artworks.show', [

View File

@@ -45,11 +45,11 @@ final class ArtworkPageController extends Controller
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$description = Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 160, '…');
$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', (string) $artwork->title, (string) $authorName),
'description' => $description !== '' ? $description : (string) $artwork->title,
'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,
@@ -93,8 +93,8 @@ final class ArtworkPageController extends Controller
return [
'id' => (int) $item->id,
'title' => (string) $item->title,
'author' => (string) ($item->user?->name ?: $item->user?->username ?: 'Artist'),
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
@@ -103,24 +103,38 @@ final class ArtworkPageController extends Controller
->values()
->all();
$comments = ArtworkComment::with(['user.profile'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderBy('created_at')
->limit(500)
->get()
->map(fn(ArtworkComment $c) => [
'id' => $c->id,
'content' => (string) $c->content,
'created_at' => $c->created_at?->toIsoString(),
'user' => [
// Recursive helper to format a comment and its nested replies
$formatComment = null;
$formatComment = function(ArtworkComment $c) use (&$formatComment) {
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
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' => $c->rendered_content,
'created_at' => $c->created_at?->toIsoString(),
'user' => [
'id' => $c->user?->id,
'name' => $c->user?->name,
'username' => $c->user?->username,
'display' => $c->user?->username ?? $c->user?->name ?? 'User',
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
'avatar_url' => $c->user?->profile?->avatar_url,
],
])
'replies' => $replies->map($formatComment)->values()->all(),
];
};
$comments = ArtworkComment::with(['user.profile', 'approvedReplies'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderBy('created_at')
->limit(500)
->get()
->map($formatComment)
->values()
->all();