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

@@ -40,6 +40,7 @@ class ConfigureMeilisearchIndex extends Command
* Fields used in filter expressions (AND category = "" etc.).
*/
private const FILTERABLE_ATTRIBUTES = [
'id',
'is_public',
'is_approved',
'category',
@@ -52,7 +53,8 @@ class ConfigureMeilisearchIndex extends Command
public function handle(): int
{
$indexName = (string) $this->option('index');
$prefix = config('scout.prefix', '');
$indexName = $prefix . (string) $this->option('index');
/** @var MeilisearchClient $client */
$client = app(MeilisearchClient::class);

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();

View File

@@ -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,
],
];
}

View File

@@ -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(),
];
}

View File

@@ -31,6 +31,7 @@ class ArtworkComment extends Model
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
@@ -51,6 +52,27 @@ class ArtworkComment extends Model
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
/**
* Recursively eager-load approved replies (tree structure).
*/
public function approvedReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('is_approved', true)
->orderBy('created_at')
->with(['user.profile', 'approvedReplies']);
}
public function reactions(): HasMany
{
return $this->hasMany(CommentReaction::class, 'comment_id');

View File

@@ -197,7 +197,7 @@ class ContentSanitizer
// Suppress warnings from malformed fragments
libxml_use_internal_errors(true);
$doc->loadHTML(
'<html><body>' . $html . '</body></html>',
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();

View File

@@ -253,7 +253,7 @@ final class UserStatsService
DB::table('user_statistics')
->where('user_id', $userId)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"),
'updated_at' => now(),
]);
}
@@ -264,7 +264,7 @@ final class UserStatsService
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"),
'updated_at' => now(),
]);
}

View File

@@ -33,7 +33,7 @@ class AvatarUrl
{
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
return sprintf('%s/avatars/default.webp', $base);
return sprintf('%s/default/avatar_default.webp', $base);
}
private static function resolveHash(int $userId): ?string