From eee7df1f8c698b8e0e6d21e76dcec091abd3a79a Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 28 Feb 2026 14:05:39 +0100 Subject: [PATCH] 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 --- .../Commands/ConfigureMeilisearchIndex.php | 4 +- .../Api/ArtworkCommentController.php | 46 +- app/Http/Controllers/Api/RankController.php | 1 + .../Api/SimilarArtworksController.php | 4 +- app/Http/Controllers/ArtworkController.php | 4 +- .../Controllers/Web/ArtworkPageController.php | 48 +- app/Http/Resources/ArtworkListResource.php | 22 +- app/Http/Resources/ArtworkResource.php | 19 +- app/Models/ArtworkComment.php | 22 + app/Services/ContentSanitizer.php | 2 +- app/Services/UserStatsService.php | 4 +- app/Support/AvatarUrl.php | 2 +- ...dd_parent_id_to_artwork_comments_table.php | 25 + resources/js/Pages/ArtworkPage.jsx | 132 ++-- .../js/Pages/Home/HomeBecauseYouLike.jsx | 2 +- resources/js/Pages/Home/HomeCreators.jsx | 2 +- resources/js/Pages/Home/HomeFresh.jsx | 2 +- resources/js/Pages/Home/HomeFromFollowing.jsx | 2 +- .../js/Pages/Home/HomeSuggestedCreators.jsx | 2 +- resources/js/Pages/Home/HomeTrending.jsx | 2 +- .../js/Pages/Home/HomeTrendingForYou.jsx | 2 +- resources/js/Pages/Home/HomeWelcomeRow.jsx | 2 +- resources/js/components/Topbar.jsx | 2 +- .../components/artwork/ArtworkActionBar.jsx | 324 ++++++++++ .../js/components/artwork/ArtworkAwards.jsx | 8 +- .../js/components/artwork/ArtworkCardMini.jsx | 33 + .../js/components/artwork/ArtworkComments.jsx | 580 +++++++++++++----- .../components/artwork/ArtworkDescription.jsx | 11 +- .../artwork/ArtworkDetailsDrawer.jsx | 91 +++ .../artwork/ArtworkDetailsPanel.jsx | 84 +++ .../js/components/artwork/ArtworkHero.jsx | 175 +++--- .../js/components/artwork/ArtworkMeta.jsx | 29 +- .../components/artwork/ArtworkReactions.jsx | 5 +- .../artwork/ArtworkRecommendationsRails.jsx | 365 +++++++++++ .../js/components/artwork/ArtworkTags.jsx | 84 ++- .../components/artwork/CreatorSpotlight.jsx | 165 +++++ .../js/components/comments/CommentForm.jsx | 421 +++++++++++-- .../js/components/comments/CommentsFeed.jsx | 2 +- .../js/components/comments/ReactionBar.jsx | 208 +++++-- .../js/components/gallery/ArtworkCard.jsx | 2 +- resources/views/artworks/show.blade.php | 8 +- resources/views/web/creators/rising.blade.php | 2 +- routes/api.php | 2 +- scripts/check_col_charset.php | 11 + scripts/check_reply_tree.php | 33 + scripts/rerender_comments.php | 38 ++ 46 files changed, 2536 insertions(+), 498 deletions(-) create mode 100644 database/migrations/2026_02_28_000001_add_parent_id_to_artwork_comments_table.php create mode 100644 resources/js/components/artwork/ArtworkActionBar.jsx create mode 100644 resources/js/components/artwork/ArtworkCardMini.jsx create mode 100644 resources/js/components/artwork/ArtworkDetailsDrawer.jsx create mode 100644 resources/js/components/artwork/ArtworkDetailsPanel.jsx create mode 100644 resources/js/components/artwork/ArtworkRecommendationsRails.jsx create mode 100644 resources/js/components/artwork/CreatorSpotlight.jsx create mode 100644 scripts/check_col_charset.php create mode 100644 scripts/check_reply_tree.php create mode 100644 scripts/rerender_comments.php 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 ( <> -
- - -
- - - +
+ {/* ── Hero ────────────────────────────────────────────────────── */} +
+
-
-
- - - - - - -
+ {/* ── Centered action bar with stat counts ────────────────────── */} +
+ +
- + + {/* RIGHT COLUMN — sidebar */} + +
- + {/* ── Full-width recommendation rails ─────────────────────────── */} +
+ +
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */} diff --git a/resources/js/Pages/Home/HomeBecauseYouLike.jsx b/resources/js/Pages/Home/HomeBecauseYouLike.jsx index 50b93cd3..ed3533a4 100644 --- a/resources/js/Pages/Home/HomeBecauseYouLike.jsx +++ b/resources/js/Pages/Home/HomeBecauseYouLike.jsx @@ -1,7 +1,7 @@ import React from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function ArtCard({ item }) { const username = item.author_username ? `@${item.author_username}` : null diff --git a/resources/js/Pages/Home/HomeCreators.jsx b/resources/js/Pages/Home/HomeCreators.jsx index 5365e33e..8a9393b6 100644 --- a/resources/js/Pages/Home/HomeCreators.jsx +++ b/resources/js/Pages/Home/HomeCreators.jsx @@ -1,6 +1,6 @@ import React from 'react' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function CreatorCard({ creator }) { return ( diff --git a/resources/js/Pages/Home/HomeFresh.jsx b/resources/js/Pages/Home/HomeFresh.jsx index 4a0577f0..424aabf2 100644 --- a/resources/js/Pages/Home/HomeFresh.jsx +++ b/resources/js/Pages/Home/HomeFresh.jsx @@ -1,7 +1,7 @@ import React from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function FreshCard({ item }) { const username = item.author_username ? `@${item.author_username}` : null diff --git a/resources/js/Pages/Home/HomeFromFollowing.jsx b/resources/js/Pages/Home/HomeFromFollowing.jsx index 0791d70d..bea372f8 100644 --- a/resources/js/Pages/Home/HomeFromFollowing.jsx +++ b/resources/js/Pages/Home/HomeFromFollowing.jsx @@ -1,7 +1,7 @@ import React from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function ArtCard({ item }) { const username = item.author_username ? `@${item.author_username}` : null diff --git a/resources/js/Pages/Home/HomeSuggestedCreators.jsx b/resources/js/Pages/Home/HomeSuggestedCreators.jsx index 3ee77c92..ca89d19d 100644 --- a/resources/js/Pages/Home/HomeSuggestedCreators.jsx +++ b/resources/js/Pages/Home/HomeSuggestedCreators.jsx @@ -1,6 +1,6 @@ import React from 'react' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function CreatorCard({ creator }) { return ( diff --git a/resources/js/Pages/Home/HomeTrending.jsx b/resources/js/Pages/Home/HomeTrending.jsx index b1042b21..3f1d7aa9 100644 --- a/resources/js/Pages/Home/HomeTrending.jsx +++ b/resources/js/Pages/Home/HomeTrending.jsx @@ -1,7 +1,7 @@ import React from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function ArtCard({ item }) { const username = item.author_username ? `@${item.author_username}` : null diff --git a/resources/js/Pages/Home/HomeTrendingForYou.jsx b/resources/js/Pages/Home/HomeTrendingForYou.jsx index f072d7ed..660e1b9b 100644 --- a/resources/js/Pages/Home/HomeTrendingForYou.jsx +++ b/resources/js/Pages/Home/HomeTrendingForYou.jsx @@ -1,7 +1,7 @@ import React from 'react' const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' function ArtCard({ item }) { const username = item.author_username ? `@${item.author_username}` : null diff --git a/resources/js/Pages/Home/HomeWelcomeRow.jsx b/resources/js/Pages/Home/HomeWelcomeRow.jsx index adc9cc18..e71fa9c4 100644 --- a/resources/js/Pages/Home/HomeWelcomeRow.jsx +++ b/resources/js/Pages/Home/HomeWelcomeRow.jsx @@ -1,6 +1,6 @@ import React from 'react' -const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' export default function HomeWelcomeRow({ user_data }) { if (!user_data) return null diff --git a/resources/js/components/Topbar.jsx b/resources/js/components/Topbar.jsx index e912a0a0..943a43fc 100644 --- a/resources/js/components/Topbar.jsx +++ b/resources/js/components/Topbar.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import SearchBar from '../Search/SearchBar' -const DEFAULT_AVATAR = 'https://files.skinbase.org/avatars/default.webp' +const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp' export default function Topbar({ user = null }) { const [menuOpen, setMenuOpen] = useState(false) diff --git a/resources/js/components/artwork/ArtworkActionBar.jsx b/resources/js/components/artwork/ArtworkActionBar.jsx new file mode 100644 index 00000000..b961d720 --- /dev/null +++ b/resources/js/components/artwork/ArtworkActionBar.jsx @@ -0,0 +1,324 @@ +import React, { useEffect, useState } from 'react' + +function formatCount(value) { + const n = Number(value || 0) + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k` + return `${n}` +} + +/* ── SVG Icons ─────────────────────────────────────────────────────────────── */ +function HeartIcon({ filled }) { + return filled ? ( + + + + ) : ( + + + + ) +} + +function BookmarkIcon({ filled }) { + return filled ? ( + + + + ) : ( + + + + ) +} + +function CloudDownIcon() { + return ( + + + + ) +} + +function DownloadArrowIcon() { + return ( + + + + ) +} + +function ShareIcon() { + return ( + + + + ) +} + +function FlagIcon() { + return ( + + + + ) +} + +export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) { + const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked)) + const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited)) + const [downloading, setDownloading] = useState(false) + const [reporting, setReporting] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + setLiked(Boolean(artwork?.viewer?.is_liked)) + setFavorited(Boolean(artwork?.viewer?.is_favorited)) + }, [artwork?.id, artwork?.viewer?.is_liked, artwork?.viewer?.is_favorited]) + + const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#' + const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#') + const csrfToken = typeof document !== 'undefined' + ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + : null + + // Track view + useEffect(() => { + if (!artwork?.id) return + const key = `sb_viewed_${artwork.id}` + if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(key)) return + fetch(`/api/art/${artwork.id}/view`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }).then(res => { + if (res.ok && typeof sessionStorage !== 'undefined') sessionStorage.setItem(key, '1') + }).catch(() => {}) + }, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps + + const postInteraction = async (url, body) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken || '' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }) + if (!response.ok) throw new Error('Request failed') + return response.json() + } + + const handleDownload = async () => { + if (downloading || !artwork?.id) return + setDownloading(true) + try { + const res = await fetch(`/api/art/${artwork.id}/download`, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }) + const data = res.ok ? await res.json() : null + const url = data?.url || fallbackUrl + const a = document.createElement('a') + a.href = url + a.download = data?.filename || '' + a.rel = 'noopener noreferrer' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } catch { + window.open(fallbackUrl, '_blank', 'noopener,noreferrer') + } finally { + setDownloading(false) + } + } + + const onToggleLike = async () => { + const nextState = !liked + setLiked(nextState) + try { + await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState }) + onStatsChange?.({ likes: nextState ? 1 : -1 }) + } catch { setLiked(!nextState) } + } + + const onToggleFavorite = async () => { + const nextState = !favorited + setFavorited(nextState) + try { + await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState }) + onStatsChange?.({ favorites: nextState ? 1 : -1 }) + } catch { setFavorited(!nextState) } + } + + const onShare = async () => { + try { + if (navigator.share) { + await navigator.share({ title: artwork?.title || 'Artwork', url: shareUrl }) + return + } + await navigator.clipboard.writeText(shareUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { /* noop */ } + } + + const onReport = async () => { + if (reporting) return + setReporting(true) + try { + await postInteraction(`/api/artworks/${artwork.id}/report`, { reason: 'Reported from artwork page' }) + } catch { /* noop */ } + finally { setReporting(false) } + } + + const likeCount = formatCount(stats?.likes ?? artwork?.stats?.likes ?? 0) + const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0) + const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0) + + return ( + <> + {/* ── Desktop centered bar ────────────────────────────────────── */} +
+ {/* Like stat pill */} + + + {/* Favorite/bookmark stat pill */} + + + {/* Views stat pill */} +
+ + {viewCount} +
+ + {/* Share pill */} + + + {/* Report pill */} + + + {/* Download button */} + +
+ + {/* ── Mobile fixed bottom bar ─────────────────────────────────── */} +
+
+ + + + + {/* Share */} + + + {/* Report */} + + + +
+
+ + ) +} diff --git a/resources/js/components/artwork/ArtworkAwards.jsx b/resources/js/components/artwork/ArtworkAwards.jsx index 8ad0dc86..1a406a02 100644 --- a/resources/js/components/artwork/ArtworkAwards.jsx +++ b/resources/js/components/artwork/ArtworkAwards.jsx @@ -136,8 +136,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent }, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse]) return ( -
-

Awards

+
+

Awards

{error && (

{error}

@@ -158,8 +158,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent className={[ 'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all', isActive - ? 'border-accent bg-accent/10 font-semibold text-accent' - : 'border-nova-600 text-white hover:bg-nova-800', + ? 'border-accent/40 bg-accent/10 font-semibold text-accent shadow-lg shadow-accent/10' + : 'border-white/[0.08] bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:border-white/[0.12]', (!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60', ].filter(Boolean).join(' ')} > diff --git a/resources/js/components/artwork/ArtworkCardMini.jsx b/resources/js/components/artwork/ArtworkCardMini.jsx new file mode 100644 index 00000000..77365b32 --- /dev/null +++ b/resources/js/components/artwork/ArtworkCardMini.jsx @@ -0,0 +1,33 @@ +import React from 'react' + +const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp' + +export default function ArtworkCardMini({ item }) { + if (!item?.url) return null + + return ( + + ) +} diff --git a/resources/js/components/artwork/ArtworkComments.jsx b/resources/js/components/artwork/ArtworkComments.jsx index 6993f8fb..e118dbca 100644 --- a/resources/js/components/artwork/ArtworkComments.jsx +++ b/resources/js/components/artwork/ArtworkComments.jsx @@ -20,6 +20,32 @@ function timeAgo(dateStr) { return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) } +/* ── Icons ─────────────────────────────────────────────────────────────────── */ +function ReplyIcon() { + return ( + + + + ) +} + +function ChatBubbleIcon() { + return ( + + + + ) +} + +function ChevronDownIcon({ className }) { + return ( + + + + ) +} + +/* ── Avatar ─────────────────────────────────────────────────────────────────── */ function Avatar({ user, size = 36 }) { if (user?.avatar_url) { return ( @@ -28,12 +54,12 @@ function Avatar({ user, size = 36 }) { alt={user.name || user.username || ''} width={size} height={size} - className="rounded-full object-cover shrink-0" + className="rounded-full object-cover shrink-0 ring-1 ring-white/10" style={{ width: size, height: size }} loading="lazy" onError={(e) => { e.currentTarget.onerror = null - e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp' + e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp' }} /> ) @@ -41,7 +67,7 @@ function Avatar({ user, size = 36 }) { const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase() return ( {initials} @@ -49,21 +75,172 @@ function Avatar({ user, size = 36 }) { ) } -// ── Single comment ──────────────────────────────────────────────────────────── +// ── Reply item (nested under a parent) ──────────────────────────────────────── -function CommentItem({ comment, isLoggedIn }) { - const user = comment.user - const html = comment.rendered_content ?? null - const plain = comment.content ?? '' +function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, depth = 1 }) { + const user = reply.user + const html = reply.rendered_content ?? null + const plain = reply.content ?? reply.raw_content ?? '' + const profileLabel = user?.display || user?.username || user?.name || 'Member' + const replies = reply.replies || [] - // Emoji-flood collapse: long runs of repeated emoji get a show-more toggle. - const flood = isFlood(plain) + const [showReplyForm, setShowReplyForm] = useState(false) + const [showAllReplies, setShowAllReplies] = useState(false) + const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {}) + + useEffect(() => { + if (reply.reactions || !reply.id) return + axios + .get(`/api/comments/${reply.id}/reactions`) + .then(({ data }) => setReactionTotals(data.totals ?? {})) + .catch(() => {}) + }, [reply.id, reply.reactions]) + + const handleReplyPosted = useCallback((newReply) => { + // Reply posts under THIS reply's id as parent + onReplyPosted?.(reply.id, newReply) + setShowReplyForm(false) + setShowAllReplies(true) + }, [reply.id, onReplyPosted]) + + // Show first 2 nested replies, expand to show all + const visibleReplies = showAllReplies ? replies : replies.slice(0, 2) + const hiddenReplyCount = replies.length - 2 + + // Shrink avatar at deeper levels + const avatarSize = depth >= 3 ? 22 : 28 + + return ( +
  • +
    + {user?.profile_url ? ( + + + + ) : ( + + )} + +
    +
    + {user?.profile_url ? ( + + {profileLabel} + + ) : ( + {profileLabel} + )} + + +
    + + {html ? ( +
    + ) : ( +

    {plain}

    + )} + + {/* Actions — Reply + React inline */} +
    + {isLoggedIn && ( + + )} + + +
    + + {/* Inline reply form */} + {showReplyForm && ( +
    + setShowReplyForm(false)} + onPosted={handleReplyPosted} + isLoggedIn={isLoggedIn} + compact + /> +
    + )} + + {/* Nested replies (tree) */} + {replies.length > 0 && ( +
    +
      = 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}> + {visibleReplies.map((child) => ( + + ))} +
    + + {!showAllReplies && hiddenReplyCount > 0 && ( + + )} +
    + )} +
    +
    +
  • + ) +} + +// ── Single comment (top-level) ──────────────────────────────────────────────── + +function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) { + const user = comment.user + const html = comment.rendered_content ?? null + const plain = comment.content ?? comment.raw_content ?? '' + const profileLabel = user?.display || user?.username || user?.name || 'Member' + const replies = comment.replies || [] + + const flood = isFlood(plain) const [expanded, setExpanded] = useState(!flood) + const [showReplyForm, setShowReplyForm] = useState(false) + const [showAllReplies, setShowAllReplies] = useState(false) - // Build initial reaction totals (empty if not provided by server) const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {}) - // Load reactions lazily if not provided useEffect(() => { if (comment.reactions || !comment.id) return axios @@ -72,92 +249,159 @@ function CommentItem({ comment, isLoggedIn }) { .catch(() => {}) }, [comment.id, comment.reactions]) - return ( -
  • - {/* Avatar */} - {user?.profile_url ? ( - - ) : ( - - - - )} + const handleReplyPosted = useCallback((newReply) => { + onReplyPosted?.(comment.id, newReply) + setShowReplyForm(false) + setShowAllReplies(true) + }, [comment.id, onReplyPosted]) - {/* Content */} -
    - {/* Header */} -
    + // Show first 2 replies by default, expand to show all + const visibleReplies = showAllReplies ? replies : replies.slice(0, 2) + const hiddenReplyCount = replies.length - 2 + + return ( +
  • +
    +
    + {/* Avatar */} {user?.profile_url ? ( - - {user.display || user.username || user.name || 'Member'} + ) : ( - - {user?.display || user?.username || user?.name || 'Member'} - + )} - -
    - {/* Body — use rendered_content (safe HTML) when available, else plain text */} - {/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */} -
    - {html ? ( + {/* Content */} +
    + {/* Header */} +
    + {user?.profile_url ? ( + + {profileLabel} + + ) : ( + {profileLabel} + )} + + +
    + + {/* Body */}
    - ) : ( -

    - {plain} -

    - )} + className={!expanded ? 'overflow-hidden relative' : undefined} + style={!expanded ? { maxHeight: '5em' } : undefined} + > + {html ? ( +
    + ) : ( +

    {plain}

    + )} - {/* Gradient fade at the bottom while collapsed */} - {flood && !expanded && ( -
    - - {/* Flood expand / collapse toggle */} - {flood && ( - - )} - - {/* Reactions */} - {Object.keys(reactionTotals).length > 0 && ( - - )}
    + + {/* ── Replies thread ───────────────────────────────────────────────── */} + {(replies.length > 0 || showReplyForm) && ( +
    + {replies.length > 0 && ( + <> +
      + {visibleReplies.map((reply) => ( + + ))} +
    + + {!showAllReplies && hiddenReplyCount > 0 && ( + + )} + + )} + + {/* Inline reply form */} + {showReplyForm && ( +
    0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}> + setShowReplyForm(false)} + onPosted={handleReplyPosted} + isLoggedIn={isLoggedIn} + compact + /> +
    + )} +
    + )}
  • ) } @@ -166,14 +410,24 @@ function CommentItem({ comment, isLoggedIn }) { function Skeleton() { return ( -
    +
    {Array.from({ length: 3 }).map((_, i) => ( -
    -
    -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    ))} @@ -183,19 +437,6 @@ function Skeleton() { // ── Main export ─────────────────────────────────────────────────────────────── -/** - * ArtworkComments - * - * Can operate in two modes: - * 1. Static: pass `comments` array from Inertia page props (legacy / SSR) - * 2. Dynamic: pass `artworkId` to load + post comments via the API - * - * Props: - * artworkId number Used for API calls - * comments array SSR initial comments (optional) - * isLoggedIn boolean - * loginUrl string - */ export default function ArtworkComments({ artworkId, comments: initialComments = [], @@ -209,7 +450,6 @@ export default function ArtworkComments({ const [total, setTotal] = useState(initialComments.length) const initialized = useRef(false) - // Load comments from API const loadComments = useCallback( async (p = 1) => { if (!artworkId) return @@ -225,7 +465,7 @@ export default function ArtworkComments({ setLastPage(data.meta?.last_page ?? 1) setTotal(data.meta?.total ?? 0) } catch { - // keep existing data on error + // keep existing } finally { setLoading(false) } @@ -233,7 +473,6 @@ export default function ArtworkComments({ [artworkId], ) - // On mount, load if artworkId provided and no SSR comments given useEffect(() => { if (initialized.current) return initialized.current = true @@ -245,21 +484,95 @@ export default function ArtworkComments({ } }, [artworkId, initialComments.length, loadComments]) + // New top-level comment posted const handlePosted = useCallback((newComment) => { - setComments((prev) => [newComment, ...prev]) + // Ensure it has a replies array + const comment = { ...newComment, replies: newComment.replies || [] } + setComments((prev) => [comment, ...prev]) + setTotal((t) => t + 1) + }, []) + + // Reply posted under a parent comment (works at any nesting depth) + const handleReplyPosted = useCallback((parentId, newReply) => { + // Recursively find the parent node and append the reply + const insertReply = (nodes) => + nodes.map((c) => { + if (c.id === parentId) { + return { ...c, replies: [...(c.replies || []), { ...newReply, replies: [] }] } + } + if (c.replies?.length) { + return { ...c, replies: insertReply(c.replies) } + } + return c + }) + + setComments((prev) => insertReply(prev)) setTotal((t) => t + 1) }, []) return (
    -

    - Comments{' '} + {/* Section header */} +
    +

    + Comments +

    {total > 0 && ( - ({total}) + + {total} + )} -

    +
    - {/* Comment form */} + {/* Comment list */} + {loading && comments.length === 0 ? ( + + ) : comments.length === 0 ? ( +
    + +

    No comments yet

    +

    Be the first to share your thoughts.

    +
    + ) : ( + <> +
      + {comments.map((comment) => ( + + ))} +
    + + {page < lastPage && ( +
    + +
    + )} + + )} + + {/* Comment form — after all comments */} {artworkId && ( )} - - {/* Comment list */} - {loading && comments.length === 0 ? ( - - ) : comments.length === 0 ? ( -

    No comments yet. Be the first!

    - ) : ( - <> -
      - {comments.map((comment) => ( - - ))} -
    - - {/* Load more */} - {page < lastPage && ( -
    - -
    - )} - - )} ) } diff --git a/resources/js/components/artwork/ArtworkDescription.jsx b/resources/js/components/artwork/ArtworkDescription.jsx index 75753595..d6a3c8ae 100644 --- a/resources/js/components/artwork/ArtworkDescription.jsx +++ b/resources/js/components/artwork/ArtworkDescription.jsx @@ -39,7 +39,7 @@ function renderMarkdownSafe(text) { } return ( -

    +

    {parts}

    ) @@ -60,19 +60,18 @@ export default function ArtworkDescription({ artwork }) { if (content.length === 0) return null return ( -
    -

    Description

    -
    {rendered}
    +
    +
    {rendered}
    {content.length > COLLAPSE_AT && ( )} -
    +
    ) } diff --git a/resources/js/components/artwork/ArtworkDetailsDrawer.jsx b/resources/js/components/artwork/ArtworkDetailsDrawer.jsx new file mode 100644 index 00000000..2ef7883f --- /dev/null +++ b/resources/js/components/artwork/ArtworkDetailsDrawer.jsx @@ -0,0 +1,91 @@ +import React, { useMemo } from 'react' +import ArtworkBreadcrumbs from './ArtworkBreadcrumbs' + +function formatCount(value) { + const number = Number(value || 0) + if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k` + return `${number}` +} + +function formatDate(value) { + if (!value) return '—' + try { + return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + } catch { + return '—' + } +} + +export default function ArtworkDetailsDrawer({ isOpen, onClose, artwork, stats }) { + const width = artwork?.dimensions?.width || artwork?.width || 0 + const height = artwork?.dimensions?.height || artwork?.height || 0 + + const fileType = useMemo(() => { + const mime = artwork?.file?.mime_type || artwork?.mime_type || '' + if (mime) return mime + const url = artwork?.file?.url || artwork?.thumbs?.xl?.url || '' + const ext = url.split('.').pop() + return ext ? ext.toUpperCase() : '—' + }, [artwork]) + + if (!isOpen) return null + + return ( +
    + +
    + +
    + +
    + +
    +
    +
    Resolution
    +
    {width > 0 && height > 0 ? `${width} × ${height}` : '—'}
    +
    +
    +
    Upload date
    +
    {formatDate(artwork?.published_at)}
    +
    +
    +
    File type
    +
    {fileType}
    +
    +
    +
    Views
    +
    {formatCount(stats?.views)}
    +
    +
    +
    Downloads
    +
    {formatCount(stats?.downloads)}
    +
    +
    +
    Favorites
    +
    {formatCount(stats?.favorites)}
    +
    +
    +
    +
    + ) +} diff --git a/resources/js/components/artwork/ArtworkDetailsPanel.jsx b/resources/js/components/artwork/ArtworkDetailsPanel.jsx new file mode 100644 index 00000000..239ff0ec --- /dev/null +++ b/resources/js/components/artwork/ArtworkDetailsPanel.jsx @@ -0,0 +1,84 @@ +import React from 'react' + +function formatCount(value) { + const n = Number(value || 0) + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k` + return n.toLocaleString() +} + +function formatDate(value) { + if (!value) return '—' + try { + const d = new Date(value) + const now = Date.now() + const diff = now - d.getTime() + const days = Math.floor(diff / 86_400_000) + if (days === 0) return 'Today' + if (days === 1) return 'Yesterday' + if (days < 30) return `${days} days ago` + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + } catch { + return '—' + } +} + +/* ── Stat tile shown in the 2-col grid ─────────────────────────────────── */ +function StatTile({ icon, label, value }) { + return ( +
    + {icon} + {value} + {label} +
    + ) +} + +/* ── Key-value row ─────────────────────────────────────────────────────── */ +function InfoRow({ label, value }) { + return ( +
    + {label} + {value} +
    + ) +} + +export default function ArtworkDetailsPanel({ artwork, stats }) { + const width = artwork?.dimensions?.width || artwork?.width || 0 + const height = artwork?.dimensions?.height || artwork?.height || 0 + const resolution = width > 0 && height > 0 ? `${width.toLocaleString()} × ${height.toLocaleString()}` : null + + return ( +
    + {/* Stats grid */} +
    + + + + + } + label="Views" + value={formatCount(stats?.views)} + /> + + + + } + label="Downloads" + value={formatCount(stats?.downloads)} + /> +
    + + {/* Info rows */} +
    + {resolution && } + +
    +
    + ) +} diff --git a/resources/js/components/artwork/ArtworkHero.jsx b/resources/js/components/artwork/ArtworkHero.jsx index 85bb2f8d..2401ea4c 100644 --- a/resources/js/components/artwork/ArtworkHero.jsx +++ b/resources/js/components/artwork/ArtworkHero.jsx @@ -18,102 +18,117 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource) const blurBackdropSrc = mdSource || lgSource || xlSource || null + const width = Number(artwork?.width) + const height = Number(artwork?.height) + const hasKnownAspect = width > 0 && height > 0 + const aspectRatio = hasKnownAspect ? `${width} / ${height}` : '16 / 9' + const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w` return ( -
    -
    +
    + {blurBackdropSrc && ( + <> + +
    +
    + + )} - {/* Outer flex row: left arrow | image | right arrow */} -
    - - {/* Prev arrow — outside the picture */} -
    - {hasPrev && ( - - )} -
    - - {/* Image area */} -
    - {hasRealArtworkImage && ( -
    - )} - -
    e.key === 'Enter' && onOpenViewer() : undefined} +
    +
    + {hasPrev && ( + + )} +
    - {artwork?.title setIsLoaded(true)} - onError={(event) => { - event.currentTarget.src = FALLBACK_LG - }} - /> +
    +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenViewer() + } + } : undefined} + > + {artwork?.title - {onOpenViewer && ( - - )} -
    + {artwork?.title setIsLoaded(true)} + onError={(event) => { + event.currentTarget.src = FALLBACK_LG + }} + /> - {hasRealArtworkImage && ( -
    - )} -
    - - {/* Next arrow — outside the picture */} -
    - {hasNext && ( + {onOpenViewer && ( )}
    + {hasRealArtworkImage && ( +
    + )} +
    + +
    + {hasNext && ( + + )}
    diff --git a/resources/js/components/artwork/ArtworkMeta.jsx b/resources/js/components/artwork/ArtworkMeta.jsx index 058b4428..1149b993 100644 --- a/resources/js/components/artwork/ArtworkMeta.jsx +++ b/resources/js/components/artwork/ArtworkMeta.jsx @@ -2,31 +2,12 @@ import React from 'react' import ArtworkBreadcrumbs from './ArtworkBreadcrumbs' export default function ArtworkMeta({ artwork }) { - const author = artwork?.user?.name || artwork?.user?.username || 'Artist' - const publishedAt = artwork?.published_at - ? new Date(artwork.published_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) - : '—' - const width = artwork?.dimensions?.width || 0 - const height = artwork?.dimensions?.height || 0 - return ( -
    -

    {artwork?.title}

    - -
    -
    -
    Author
    -
    {author}
    -
    -
    -
    Upload date
    -
    {publishedAt}
    -
    -
    -
    Resolution
    -
    {width > 0 && height > 0 ? `${width} × ${height}` : '—'}
    -
    -
    +
    +

    {artwork?.title}

    +
    + +
    ) } diff --git a/resources/js/components/artwork/ArtworkReactions.jsx b/resources/js/components/artwork/ArtworkReactions.jsx index d5bf91b3..3c3febeb 100644 --- a/resources/js/components/artwork/ArtworkReactions.jsx +++ b/resources/js/components/artwork/ArtworkReactions.jsx @@ -29,13 +29,14 @@ export default function ArtworkReactions({ artworkId, isLoggedIn = false }) { } return ( -
    +
    +

    Reactions

    -
    + ) } diff --git a/resources/js/components/artwork/ArtworkRecommendationsRails.jsx b/resources/js/components/artwork/ArtworkRecommendationsRails.jsx new file mode 100644 index 00000000..d12311b1 --- /dev/null +++ b/resources/js/components/artwork/ArtworkRecommendationsRails.jsx @@ -0,0 +1,365 @@ +import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react' + +const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' + +/* ── normalizers ─────────────────────────────────────────────────── */ + +function normalizeRelated(item) { + if (!item?.url) return null + return { + id: item.id || item.slug || item.url, + title: item.title || 'Untitled', + author: item.author || 'Artist', + authorAvatar: item.author_avatar || null, + url: item.url, + thumb: item.thumb || null, + thumbSrcSet: item.thumb_srcset || null, + } +} + +function normalizeSimilar(item) { + if (!item?.url) return null + return { + id: item.id || item.slug || item.url, + title: item.title || 'Untitled', + author: item.author || 'Artist', + authorAvatar: item.author_avatar || null, + url: item.url, + thumb: item.thumb || null, + thumbSrcSet: item.thumb_srcset || null, + } +} + +function normalizeRankItem(item) { + const url = item?.urls?.direct || item?.urls?.web || item?.url || null + if (!url) return null + return { + id: item.id || item.slug || url, + title: item.title || 'Untitled', + author: item?.author?.name || 'Artist', + authorAvatar: item?.author?.avatar_url || null, + url, + thumb: item.thumbnail_url || item.thumb || null, + thumbSrcSet: null, + } +} + +function dedupeByUrl(items) { + const seen = new Set() + return items.filter((item) => { + if (!item?.url || seen.has(item.url)) return false + seen.add(item.url) + return true + }) +} + +/* ── Large art card (matches homepage style) ─────────────────── */ + +function RailCard({ item }) { + return ( + + ) +} + +/* ── Scroll arrow button ─────────────────────────────────────── */ + +function ScrollBtn({ direction, onClick, visible }) { + if (!visible) return null + const isLeft = direction === 'left' + return ( + + ) +} + +/* ── Rail section (infinite loop + mouse-wheel scroll) ───────── */ + +function Rail({ title, emoji, items, seeAllHref }) { + const scrollRef = useRef(null) + const isResettingRef = useRef(false) + const scrollEndTimer = useRef(null) + const itemCount = items.length + + /* Triple items so we can loop seamlessly: [clone|original|clone] */ + const loopItems = useMemo(() => { + if (!items.length) return [] + return [...items, ...items, ...items] + }, [items]) + + /* Pixel width of one item-set (measured from the DOM) */ + const getSetWidth = useCallback(() => { + const el = scrollRef.current + if (!el || el.children.length < itemCount + 1) return 0 + return el.children[itemCount].offsetLeft - el.children[0].offsetLeft + }, [itemCount]) + + /* Centre on the middle (real) set after mount / data change */ + useEffect(() => { + const el = scrollRef.current + if (!el || !itemCount) return + requestAnimationFrame(() => { + const sw = getSetWidth() + if (sw) { + el.style.scrollBehavior = 'auto' + el.scrollLeft = sw + el.style.scrollBehavior = '' + } + }) + }, [loopItems, getSetWidth, itemCount]) + + /* After scroll settles, silently jump back to the middle set if in a clone zone */ + const resetIfNeeded = useCallback(() => { + if (isResettingRef.current) return + const el = scrollRef.current + if (!el || !itemCount) return + const setW = getSetWidth() + if (setW === 0) return + + if (el.scrollLeft < setW) { + isResettingRef.current = true + el.style.scrollBehavior = 'auto' + el.scrollLeft += setW + el.style.scrollBehavior = '' + requestAnimationFrame(() => { isResettingRef.current = false }) + } else if (el.scrollLeft >= setW * 2) { + isResettingRef.current = true + el.style.scrollBehavior = 'auto' + el.scrollLeft -= setW + el.style.scrollBehavior = '' + requestAnimationFrame(() => { isResettingRef.current = false }) + } + }, [getSetWidth, itemCount]) + + /* Scroll listener: debounced boundary check + resize re-centre */ + useEffect(() => { + const el = scrollRef.current + if (!el) return + const onScroll = () => { + clearTimeout(scrollEndTimer.current) + scrollEndTimer.current = setTimeout(resetIfNeeded, 80) + } + el.addEventListener('scroll', onScroll, { passive: true }) + + const onResize = () => { + const sw = getSetWidth() + if (sw) { + el.style.scrollBehavior = 'auto' + el.scrollLeft = sw + el.style.scrollBehavior = '' + } + } + window.addEventListener('resize', onResize) + return () => { + el.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onResize) + clearTimeout(scrollEndTimer.current) + } + }, [loopItems, resetIfNeeded, getSetWidth]) + + /* Mouse-wheel → horizontal scroll (re-attach when items arrive) */ + useEffect(() => { + const el = scrollRef.current + if (!el || !loopItems.length) return + const onWheel = (e) => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + e.preventDefault() + el.scrollLeft += e.deltaY + } + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + }, [loopItems]) + + const scroll = useCallback((dir) => { + const el = scrollRef.current + if (!el) return + const amount = el.clientWidth * 0.75 + el.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' }) + }, []) + + if (!items.length) return null + + return ( +
    +
    +

    + {emoji && {emoji}}{title} +

    + {seeAllHref && ( + + See all → + + )} +
    + +
    + {/* Permanent edge fades for infinite illusion */} +
    +
    + + scroll('left')} visible={true} /> + scroll('right')} visible={true} /> + +
    + {loopItems.map((item, idx) => ( + + ))} +
    +
    +
    + ) +} + +/* ── Main export ─────────────────────────────────────────────── */ + +export default function ArtworkRecommendationsRails({ artwork, related = [] }) { + const [similarApiItems, setSimilarApiItems] = useState([]) + const [similarLoaded, setSimilarLoaded] = useState(false) + const [trendingItems, setTrendingItems] = useState([]) + + const relatedCards = useMemo(() => { + return dedupeByUrl((Array.isArray(related) ? related : []).map(normalizeRelated).filter(Boolean)) + }, [related]) + + useEffect(() => { + let isCancelled = false + + const loadSimilar = async () => { + if (!artwork?.id) { + setSimilarApiItems([]) + setSimilarLoaded(true) + return + } + + try { + const response = await fetch(`/api/art/${artwork.id}/similar`, { credentials: 'same-origin' }) + if (!response.ok) throw new Error('similar fetch failed') + const payload = await response.json() + const items = dedupeByUrl((payload?.data || []).map(normalizeSimilar).filter(Boolean)) + if (!isCancelled) { + setSimilarApiItems(items) + setSimilarLoaded(true) + } + } catch { + if (!isCancelled) { + setSimilarApiItems([]) + setSimilarLoaded(true) + } + } + } + + loadSimilar() + return () => { + isCancelled = true + } + }, [artwork?.id]) + + useEffect(() => { + let isCancelled = false + + const loadTrending = async () => { + const categoryId = artwork?.categories?.[0]?.id + if (!categoryId) { + setTrendingItems([]) + return + } + + try { + const response = await fetch(`/api/rank/category/${categoryId}?type=trending`, { credentials: 'same-origin' }) + if (!response.ok) throw new Error('trending fetch failed') + const payload = await response.json() + const items = dedupeByUrl((payload?.data || []).map(normalizeRankItem).filter(Boolean)) + if (!isCancelled) setTrendingItems(items) + } catch { + if (!isCancelled) setTrendingItems([]) + } + } + + loadTrending() + return () => { + isCancelled = true + } + }, [artwork?.categories]) + + const authorName = String(artwork?.user?.name || artwork?.user?.username || '').trim().toLowerCase() + + const tagBasedFallback = useMemo(() => { + return relatedCards.filter((item) => String(item.author || '').trim().toLowerCase() !== authorName) + }, [relatedCards, authorName]) + + const similarItems = useMemo(() => { + if (!similarLoaded) return [] + if (similarApiItems.length > 0) return similarApiItems.slice(0, 12) + if (tagBasedFallback.length > 0) return tagBasedFallback.slice(0, 12) + return trendingItems.slice(0, 12) + }, [similarLoaded, similarApiItems, tagBasedFallback, trendingItems]) + + const trendingRailItems = useMemo(() => trendingItems.slice(0, 12), [trendingItems]) + + if (similarItems.length === 0 && trendingRailItems.length === 0) return null + + const categoryName = artwork?.categories?.[0]?.name + const trendingLabel = categoryName + ? `Trending in ${categoryName}` + : 'Trending' + + const trendingHref = categoryName + ? `/discover/trending` + : '/discover/trending' + + return ( +
    + + +
    + ) +} diff --git a/resources/js/components/artwork/ArtworkTags.jsx b/resources/js/components/artwork/ArtworkTags.jsx index 63a60601..c7845b68 100644 --- a/resources/js/components/artwork/ArtworkTags.jsx +++ b/resources/js/components/artwork/ArtworkTags.jsx @@ -4,19 +4,52 @@ export default function ArtworkTags({ artwork }) { const [expanded, setExpanded] = useState(false) const tags = useMemo(() => { - const categories = (artwork?.categories || []).map((category) => ({ - key: `cat-${category.id || category.slug}`, - label: category.name, - href: category.url || `/${category.content_type_slug}/${category.slug}`, - })) + const seen = new Set() + const contentTypeSeen = new Set() + const categoryPills = [] + + // Add content types (e.g. "Wallpapers") first, then categories, then tags + for (const category of artwork?.categories || []) { + const ctSlug = category.content_type_slug + if (ctSlug && !contentTypeSeen.has(ctSlug)) { + contentTypeSeen.add(ctSlug) + const ctName = ctSlug.charAt(0).toUpperCase() + ctSlug.slice(1) + categoryPills.push({ + key: `ct-${ctSlug}`, + label: ctName, + href: `/${ctSlug}`, + isCategory: true, + }) + } + + if (category.parent && !seen.has(category.parent.id)) { + seen.add(category.parent.id) + categoryPills.push({ + key: `cat-${category.parent.id}`, + label: category.parent.name, + href: category.parent.url || `/${category.parent.content_type_slug}/${category.parent.slug}`, + isCategory: true, + }) + } + if (!seen.has(category.id)) { + seen.add(category.id) + categoryPills.push({ + key: `cat-${category.id}`, + label: category.name, + href: category.url || `/${category.content_type_slug}/${category.slug}`, + isCategory: true, + }) + } + } const artworkTags = (artwork?.tags || []).map((tag) => ({ key: `tag-${tag.id || tag.slug}`, label: tag.name, href: `/tag/${tag.slug || ''}`, + isCategory: false, })) - return [...categories, ...artworkTags] + return [...categoryPills, ...artworkTags] }, [artwork]) if (tags.length === 0) return null @@ -24,31 +57,34 @@ export default function ArtworkTags({ artwork }) { const visible = expanded ? tags : tags.slice(0, 12) return ( -
    -
    -

    Tags & Categories

    - {tags.length > 12 && ( - - )} -
    - -
    - {visible.map((tag) => ( +
    +

    Tags & Categories

    +
    + {visible.map((tag, idx) => ( {tag.label} ))} + + {tags.length > 12 && ( + + )}
    -
    +
    ) } diff --git a/resources/js/components/artwork/CreatorSpotlight.jsx b/resources/js/components/artwork/CreatorSpotlight.jsx new file mode 100644 index 00000000..1d4be24c --- /dev/null +++ b/resources/js/components/artwork/CreatorSpotlight.jsx @@ -0,0 +1,165 @@ +import React, { useMemo, useState } from 'react' + +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp' + +function formatCount(value) { + const n = Number(value || 0) + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k` + return `${n}` +} + +function toCard(item) { + return { + id: item?.id || item?.slug || item?.url, + title: item?.title, + author: item?.author, + url: item?.url, + thumb: item?.thumb, + thumbSrcSet: item?.thumb_srcset, + } +} + +export default function CreatorSpotlight({ artwork, presentSq, related = [] }) { + const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author)) + const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0)) + + const user = artwork?.user || {} + const authorName = user.name || user.username || 'Artist' + const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#') + const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK + const csrfToken = typeof document !== 'undefined' + ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') + : null + + const creatorItems = useMemo(() => { + const filtered = (Array.isArray(related) ? related : []).filter((item) => { + const sameAuthor = String(item?.author || '').trim().toLowerCase() === String(authorName || '').trim().toLowerCase() + const notCurrent = item?.url && item.url !== artwork?.canonical_url + return sameAuthor && notCurrent + }) + + const source = filtered.length > 0 ? filtered : (Array.isArray(related) ? related : []) + return source.slice(0, 12).map(toCard) + }, [related, authorName, artwork?.canonical_url]) + + const onToggleFollow = async () => { + const nextState = !following + setFollowing(nextState) + try { + const response = await fetch(`/api/users/${user.id}/follow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken || '', + }, + credentials: 'same-origin', + body: JSON.stringify({ state: nextState }), + }) + + if (!response.ok) throw new Error('Follow failed') + const payload = await response.json() + if (typeof payload?.followers_count === 'number') { + setFollowersCount(payload.followers_count) + } + setFollowing(Boolean(payload?.is_following)) + } catch { + setFollowing(!nextState) + } + } + + return ( +
    + {/* Avatar + info — stacked for sidebar */} +
    + + {authorName} { + event.currentTarget.src = AVATAR_FALLBACK + }} + /> + + + + {authorName} + + {user.username &&

    @{user.username}

    } +

    + {followersCount.toLocaleString()} Followers +

    + + {/* Follow + Profile buttons */} +
    + + + + + Follow + + +
    +
    + + {/* More from creator rail */} + {creatorItems.length > 0 && ( +
    +
    +

    More from {authorName}

    + + + + + +
    +
    + {creatorItems.slice(0, 3).map((item, idx) => ( + +
    + {item.title +
    +
    +
    + )} +
    + ) +} diff --git a/resources/js/components/comments/CommentForm.jsx b/resources/js/components/comments/CommentForm.jsx index 3849780d..de88a0f2 100644 --- a/resources/js/components/comments/CommentForm.jsx +++ b/resources/js/components/comments/CommentForm.jsx @@ -1,28 +1,181 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import axios from 'axios' +import ReactMarkdown from 'react-markdown' import EmojiPickerButton from './EmojiPickerButton' -/** - * Comment form with emoji picker and Markdown-lite support. - * - * Props: - * artworkId number Target artwork - * onPosted (comment) => void Called when comment is successfully posted - * isLoggedIn boolean - * loginUrl string Where to redirect non-authenticated users - */ +/* ── Toolbar icon components ──────────────────────────────────────────────── */ +function BoldIcon() { + return ( + + + + ) +} + +function ItalicIcon() { + return ( + + + + + + ) +} + +function CodeIcon() { + return ( + + + + + ) +} + +function LinkIcon() { + return ( + + + + + ) +} + +function ListIcon() { + return ( + + + + + + + + + ) +} + +function QuoteIcon() { + return ( + + + + ) +} + +/* ── Toolbar button wrapper ───────────────────────────────────────────────── */ +function ToolbarBtn({ title, onClick, children }) { + return ( + + ) +} + +/* ── Main component ───────────────────────────────────────────────────────── */ export default function CommentForm({ artworkId, onPosted, isLoggedIn = false, loginUrl = '/login', + parentId = null, + replyTo = null, + onCancelReply = null, + compact = false, }) { const [content, setContent] = useState('') + const [tab, setTab] = useState('write') // 'write' | 'preview' const [submitting, setSubmitting] = useState(false) const [errors, setErrors] = useState([]) const textareaRef = useRef(null) + const formRef = useRef(null) - // Insert text at current cursor position + // Auto-focus when entering reply mode + useEffect(() => { + if (replyTo && textareaRef.current) { + textareaRef.current.focus() + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, [replyTo]) + + /* ── Helpers to wrap selected text ────────────────────────────────────── */ + const wrapSelection = useCallback((before, after) => { + const el = textareaRef.current + if (!el) return + + const start = el.selectionStart + const end = el.selectionEnd + const selected = content.slice(start, end) + const replacement = before + (selected || 'text') + after + + const next = content.slice(0, start) + replacement + content.slice(end) + setContent(next) + + requestAnimationFrame(() => { + const cursorPos = selected + ? start + replacement.length + : start + before.length + const cursorEnd = selected + ? start + replacement.length + : start + before.length + 4 + el.selectionStart = cursorPos + el.selectionEnd = cursorEnd + el.focus() + }) + }, [content]) + + const prefixLines = useCallback((prefix) => { + const el = textareaRef.current + if (!el) return + + const start = el.selectionStart + const end = el.selectionEnd + const selected = content.slice(start, end) + const lines = selected ? selected.split('\n') : [''] + const prefixed = lines.map(l => prefix + l).join('\n') + + const next = content.slice(0, start) + prefixed + content.slice(end) + setContent(next) + + requestAnimationFrame(() => { + el.selectionStart = start + el.selectionEnd = start + prefixed.length + el.focus() + }) + }, [content]) + + const insertLink = useCallback(() => { + const el = textareaRef.current + if (!el) return + + const start = el.selectionStart + const end = el.selectionEnd + const selected = content.slice(start, end) + const isUrl = /^https?:\/\//.test(selected) + const replacement = isUrl + ? `[link](${selected})` + : `[${selected || 'link'}](https://)` + + const next = content.slice(0, start) + replacement + content.slice(end) + setContent(next) + + requestAnimationFrame(() => { + if (isUrl) { + el.selectionStart = start + 1 + el.selectionEnd = start + 5 + } else { + const urlStart = start + replacement.length - 1 + el.selectionStart = urlStart - 8 + el.selectionEnd = urlStart - 1 + } + el.focus() + }) + }, [content]) + + // Insert text at cursor (for emoji picker) const insertAtCursor = useCallback((text) => { const el = textareaRef.current if (!el) { @@ -32,11 +185,9 @@ export default function CommentForm({ const start = el.selectionStart ?? content.length const end = el.selectionEnd ?? content.length - - const next = content.slice(0, start) + text + content.slice(end) + const next = content.slice(0, start) + text + content.slice(end) setContent(next) - // Restore cursor after the inserted text requestAnimationFrame(() => { el.selectionStart = start + text.length el.selectionEnd = start + text.length @@ -48,6 +199,34 @@ export default function CommentForm({ insertAtCursor(emoji) }, [insertAtCursor]) + /* ── Keyboard shortcuts ───────────────────────────────────────────────── */ + const handleKeyDown = useCallback((e) => { + const mod = e.ctrlKey || e.metaKey + if (!mod) return + + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault() + wrapSelection('**', '**') + break + case 'i': + e.preventDefault() + wrapSelection('*', '*') + break + case 'k': + e.preventDefault() + insertLink() + break + case 'e': + e.preventDefault() + wrapSelection('`', '`') + break + default: + break + } + }, [wrapSelection, insertLink]) + + /* ── Submit ───────────────────────────────────────────────────────────── */ const handleSubmit = useCallback( async (e) => { e.preventDefault() @@ -66,14 +245,18 @@ export default function CommentForm({ try { const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, { content: trimmed, + parent_id: parentId || null, }) setContent('') + setTab('write') onPosted?.(data.data) + onCancelReply?.() } catch (err) { if (err.response?.status === 422) { - const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.'] - setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors]) + const fieldErrors = err.response.data?.errors ?? {} + const allErrors = Object.values(fieldErrors).flat() + setErrors(allErrors.length ? allErrors : ['Invalid content.']) } else { setErrors(['Something went wrong. Please try again.']) } @@ -81,62 +264,174 @@ export default function CommentForm({ setSubmitting(false) } }, - [artworkId, content, isLoggedIn, loginUrl, onPosted], + [artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply], ) + /* ── Logged-out state ─────────────────────────────────────────────────── */ if (!isLoggedIn) { return ( -
    - - Sign in - {' '} - to leave a comment. +
    + + + +

    + + Sign in + {' '} + to join the conversation. +

    ) } + /* ── Editor ───────────────────────────────────────────────────────────── */ return ( -
    - {/* Textarea */} -
    -