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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -54,29 +54,37 @@ class ArtworkListResource extends JsonResource
|
||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
||||
: null;
|
||||
|
||||
$artId = $get('id');
|
||||
$directUrl = $artId && $slugVal ? '/art/' . $artId . '/' . $slugVal : null;
|
||||
|
||||
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return [
|
||||
'id' => $artId,
|
||||
'slug' => $slugVal,
|
||||
'title' => $get('title'),
|
||||
'description' => $this->when($request->boolean('include_description'), fn() => $get('description')),
|
||||
'title' => $decode($get('title')),
|
||||
'description' => $this->when($request->boolean('include_description'), fn() => $decode($get('description'))),
|
||||
'dimensions' => [
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
],
|
||||
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
||||
'author' => $this->whenLoaded('user', function () {
|
||||
'author' => $this->whenLoaded('user', function () use ($decode) {
|
||||
return [
|
||||
'name' => $this->user->name ?? null,
|
||||
'name' => $decode($this->user->name ?? null),
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
];
|
||||
}),
|
||||
'category' => $primaryCategory ? [
|
||||
'slug' => $primaryCategory->slug ?? null,
|
||||
'name' => $primaryCategory->name ?? null,
|
||||
'name' => $decode($primaryCategory->name ?? null),
|
||||
'content_type' => $contentTypeSlug,
|
||||
'url' => $webUrl,
|
||||
] : null,
|
||||
'urls' => [
|
||||
'web' => $webUrl,
|
||||
'canonical' => $webUrl,
|
||||
'web' => $webUrl ?? $directUrl,
|
||||
'direct' => $directUrl,
|
||||
'canonical' => $webUrl ?? $directUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -56,11 +56,13 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
}
|
||||
|
||||
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return [
|
||||
'id' => (int) $this->id,
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => (string) $this->title,
|
||||
'description' => (string) ($this->description ?? ''),
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -80,7 +82,7 @@ class ArtworkResource extends JsonResource
|
||||
],
|
||||
'user' => [
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => (string) ($this->user?->name ?? ''),
|
||||
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
@@ -102,14 +104,21 @@ class ArtworkResource extends JsonResource
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => (string) $category->name,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
])->values(),
|
||||
'tags' => $this->tags->map(fn ($tag) => [
|
||||
'id' => (int) $tag->id,
|
||||
'slug' => (string) $tag->slug,
|
||||
'name' => (string) $tag->name,
|
||||
'name' => html_entity_decode((string) $tag->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user