diff --git a/app/Console/Commands/ConfigureMeilisearchIndex.php b/app/Console/Commands/ConfigureMeilisearchIndex.php index 3b1efdfb..ab3c7e5a 100644 --- a/app/Console/Commands/ConfigureMeilisearchIndex.php +++ b/app/Console/Commands/ConfigureMeilisearchIndex.php @@ -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); diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php index c1ae32ca..09f237a6 100644 --- a/app/Http/Controllers/Api/ArtworkCommentController.php +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -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; } } diff --git a/app/Http/Controllers/Api/RankController.php b/app/Http/Controllers/Api/RankController.php index 6b84626f..1b0d887b 100644 --- a/app/Http/Controllers/Api/RankController.php +++ b/app/Http/Controllers/Api/RankController.php @@ -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', diff --git a/app/Http/Controllers/Api/SimilarArtworksController.php b/app/Http/Controllers/Api/SimilarArtworksController.php index c7b1a54b..a76d765c 100644 --- a/app/Http/Controllers/Api/SimilarArtworksController.php +++ b/app/Http/Controllers/Api/SimilarArtworksController.php @@ -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, diff --git a/app/Http/Controllers/ArtworkController.php b/app/Http/Controllers/ArtworkController.php index 408898bd..b301205a 100644 --- a/app/Http/Controllers/ArtworkController.php +++ b/app/Http/Controllers/ArtworkController.php @@ -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', [ diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index ca82709d..5e53e44c 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -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(); diff --git a/app/Http/Resources/ArtworkListResource.php b/app/Http/Resources/ArtworkListResource.php index 77d29662..a2d1a10d 100644 --- a/app/Http/Resources/ArtworkListResource.php +++ b/app/Http/Resources/ArtworkListResource.php @@ -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, ], ]; } diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index a5ecc220..5345a726 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -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(), ]; } diff --git a/app/Models/ArtworkComment.php b/app/Models/ArtworkComment.php index 3e9bca77..0a96ecb0 100644 --- a/app/Models/ArtworkComment.php +++ b/app/Models/ArtworkComment.php @@ -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'); diff --git a/app/Services/ContentSanitizer.php b/app/Services/ContentSanitizer.php index d3014fe4..de6a6ff5 100644 --- a/app/Services/ContentSanitizer.php +++ b/app/Services/ContentSanitizer.php @@ -197,7 +197,7 @@ class ContentSanitizer // Suppress warnings from malformed fragments libxml_use_internal_errors(true); $doc->loadHTML( - '
' . $html . '', + '' . $html . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index f8571659..042f4717 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -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(), ]); } diff --git a/app/Support/AvatarUrl.php b/app/Support/AvatarUrl.php index c3a760be..10373aef 100644 --- a/app/Support/AvatarUrl.php +++ b/app/Support/AvatarUrl.php @@ -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 diff --git a/database/migrations/2026_02_28_000001_add_parent_id_to_artwork_comments_table.php b/database/migrations/2026_02_28_000001_add_parent_id_to_artwork_comments_table.php new file mode 100644 index 00000000..5d060246 --- /dev/null +++ b/database/migrations/2026_02_28_000001_add_parent_id_to_artwork_comments_table.php @@ -0,0 +1,25 @@ +unsignedBigInteger('parent_id')->nullable()->after('user_id'); + $table->foreign('parent_id')->references('id')->on('artwork_comments')->onDelete('cascade'); + $table->index('parent_id'); + }); + } + + public function down(): void + { + Schema::table('artwork_comments', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex(['parent_id']); + $table->dropColumn('parent_id'); + }); + } +}; diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx index 0c1f9a62..8b31ae26 100644 --- a/resources/js/Pages/ArtworkPage.jsx +++ b/resources/js/Pages/ArtworkPage.jsx @@ -1,18 +1,19 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import { createRoot } from 'react-dom/client' +import axios from 'axios' import ArtworkHero from '../components/artwork/ArtworkHero' import ArtworkMeta from '../components/artwork/ArtworkMeta' -import ArtworkActions from '../components/artwork/ArtworkActions' import ArtworkAwards from '../components/artwork/ArtworkAwards' -import ArtworkStats from '../components/artwork/ArtworkStats' import ArtworkTags from '../components/artwork/ArtworkTags' -import ArtworkAuthor from '../components/artwork/ArtworkAuthor' -import ArtworkRelated from '../components/artwork/ArtworkRelated' import ArtworkDescription from '../components/artwork/ArtworkDescription' import ArtworkComments from '../components/artwork/ArtworkComments' -import ArtworkReactions from '../components/artwork/ArtworkReactions' +import ArtworkActionBar from '../components/artwork/ArtworkActionBar' +import ArtworkDetailsPanel from '../components/artwork/ArtworkDetailsPanel' +import CreatorSpotlight from '../components/artwork/CreatorSpotlight' +import ArtworkRecommendationsRails from '../components/artwork/ArtworkRecommendationsRails' import ArtworkNavigator from '../components/viewer/ArtworkNavigator' import ArtworkViewer from '../components/viewer/ArtworkViewer' +import ReactionBar from '../components/comments/ReactionBar' function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) { const [viewerOpen, setViewerOpen] = useState(false) @@ -43,6 +44,16 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present // Nav arrow state — populated by ArtworkNavigator once neighbors resolve const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null }) + // Artwork-level reactions + const [reactionTotals, setReactionTotals] = useState(null) + useEffect(() => { + if (!artwork?.id) return + axios + .get(`/api/artworks/${artwork.id}/reactions`) + .then(({ data }) => setReactionTotals(data.totals ?? {})) + .catch(() => setReactionTotals({})) + }, [artwork?.id]) + /** * Called by ArtworkNavigator after a successful no-reload navigation. * data = ArtworkResource JSON from /api/artworks/{id}/page @@ -66,50 +77,83 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present return ( <> -