feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
@@ -40,6 +40,7 @@ class ConfigureMeilisearchIndex extends Command
|
|||||||
* Fields used in filter expressions (AND category = "…" etc.).
|
* Fields used in filter expressions (AND category = "…" etc.).
|
||||||
*/
|
*/
|
||||||
private const FILTERABLE_ATTRIBUTES = [
|
private const FILTERABLE_ATTRIBUTES = [
|
||||||
|
'id',
|
||||||
'is_public',
|
'is_public',
|
||||||
'is_approved',
|
'is_approved',
|
||||||
'category',
|
'category',
|
||||||
@@ -52,7 +53,8 @@ class ConfigureMeilisearchIndex extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$indexName = (string) $this->option('index');
|
$prefix = config('scout.prefix', '');
|
||||||
|
$indexName = $prefix . (string) $this->option('index');
|
||||||
|
|
||||||
/** @var MeilisearchClient $client */
|
/** @var MeilisearchClient $client */
|
||||||
$client = app(MeilisearchClient::class);
|
$client = app(MeilisearchClient::class);
|
||||||
|
|||||||
@@ -37,14 +37,19 @@ class ArtworkCommentController extends Controller
|
|||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$perPage = 20;
|
$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('artwork_id', $artwork->id)
|
||||||
->where('is_approved', true)
|
->where('is_approved', true)
|
||||||
|
->whereNull('parent_id')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate($perPage, ['*'], 'page', $page);
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$userId = $request->user()?->id;
|
$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([
|
return response()->json([
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
@@ -67,9 +72,24 @@ class ArtworkCommentController extends Controller
|
|||||||
|
|
||||||
$request->validate([
|
$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');
|
$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
|
// Validate markdown-lite content
|
||||||
$errors = ContentSanitizer::validate($raw);
|
$errors = ContentSanitizer::validate($raw);
|
||||||
@@ -82,6 +102,7 @@ class ArtworkCommentController extends Controller
|
|||||||
$comment = ArtworkComment::create([
|
$comment = ArtworkComment::create([
|
||||||
'artwork_id' => $artwork->id,
|
'artwork_id' => $artwork->id,
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
|
'parent_id' => $parentId,
|
||||||
'content' => $raw, // legacy column (plain text fallback)
|
'content' => $raw, // legacy column (plain text fallback)
|
||||||
'raw_content' => $raw,
|
'raw_content' => $raw,
|
||||||
'rendered_content' => $rendered,
|
'rendered_content' => $rendered,
|
||||||
@@ -103,7 +124,7 @@ class ArtworkCommentController extends Controller
|
|||||||
);
|
);
|
||||||
} catch (\Throwable) {}
|
} 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']);
|
$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
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function formatComment(ArtworkComment $c, ?int $currentUserId): array
|
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
|
||||||
{
|
{
|
||||||
$user = $c->user;
|
$user = $c->user;
|
||||||
$userId = (int) ($c->user_id ?? 0);
|
$userId = (int) ($c->user_id ?? 0);
|
||||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||||
|
|
||||||
return [
|
$data = [
|
||||||
'id' => $c->id,
|
'id' => $c->id,
|
||||||
|
'parent_id' => $c->parent_id,
|
||||||
'raw_content' => $c->raw_content ?? $c->content,
|
'raw_content' => $c->raw_content ?? $c->content,
|
||||||
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
||||||
'created_at' => $c->created_at?->toIso8601String(),
|
'created_at' => $c->created_at?->toIso8601String(),
|
||||||
@@ -184,5 +206,15 @@ class ArtworkCommentController extends Controller
|
|||||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
|
||||||
|
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||||
|
} elseif ($includeReplies && $c->relationLoaded('replies')) {
|
||||||
|
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||||
|
} else {
|
||||||
|
$data['replies'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class RankController extends Controller
|
|||||||
$keyed = Artwork::whereIn('id', $ids)
|
$keyed = Artwork::whereIn('id', $ids)
|
||||||
->with([
|
->with([
|
||||||
'user:id,name',
|
'user:id,name',
|
||||||
|
'user.profile:user_id,avatar_hash',
|
||||||
'categories' => function ($q): void {
|
'categories' => function ($q): void {
|
||||||
$q->select(
|
$q->select(
|
||||||
'categories.id',
|
'categories.id',
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ final class SimilarArtworksController extends Controller
|
|||||||
->paginate(200, 'page', 1);
|
->paginate(200, 'page', 1);
|
||||||
|
|
||||||
$collection = $results->getCollection();
|
$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 ──────────────────────────────────────────────────────
|
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||||
@@ -146,6 +146,8 @@ final class SimilarArtworksController extends Controller
|
|||||||
'slug' => $item['artwork']->slug,
|
'slug' => $item['artwork']->slug,
|
||||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
'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,
|
'author_id' => $item['artwork']->user_id,
|
||||||
'orientation' => $this->orientation($item['artwork']),
|
'orientation' => $this->orientation($item['artwork']),
|
||||||
'width' => $item['artwork']->width,
|
'width' => $item['artwork']->width,
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ class ArtworkController extends Controller
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $item->id,
|
'id' => (int) $item->id,
|
||||||
'title' => (string) $item->title,
|
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'author' => (string) optional($item->user)->name,
|
'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' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
|
||||||
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
|
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
|
||||||
'url' => route('artworks.show', [
|
'url' => route('artworks.show', [
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ final class ArtworkPageController extends Controller
|
|||||||
|
|
||||||
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
|
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
|
||||||
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
|
$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 = [
|
$meta = [
|
||||||
'title' => sprintf('%s by %s | Skinbase', (string) $artwork->title, (string) $authorName),
|
'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 : (string) $artwork->title,
|
'description' => $description !== '' ? $description : html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'canonical' => $canonical,
|
'canonical' => $canonical,
|
||||||
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
|
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
|
||||||
'og_width' => $thumbXl['width'] ?? $thumbLg['width'] ?? null,
|
'og_width' => $thumbXl['width'] ?? $thumbLg['width'] ?? null,
|
||||||
@@ -93,8 +93,8 @@ final class ArtworkPageController extends Controller
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $item->id,
|
'id' => (int) $item->id,
|
||||||
'title' => (string) $item->title,
|
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'author' => (string) ($item->user?->name ?: $item->user?->username ?: 'Artist'),
|
'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]),
|
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||||
'thumb' => $md['url'] ?? null,
|
'thumb' => $md['url'] ?? null,
|
||||||
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
|
||||||
@@ -103,24 +103,38 @@ final class ArtworkPageController extends Controller
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$comments = ArtworkComment::with(['user.profile'])
|
// Recursive helper to format a comment and its nested replies
|
||||||
->where('artwork_id', $artwork->id)
|
$formatComment = null;
|
||||||
->where('is_approved', true)
|
$formatComment = function(ArtworkComment $c) use (&$formatComment) {
|
||||||
->orderBy('created_at')
|
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||||
->limit(500)
|
|
||||||
->get()
|
return [
|
||||||
->map(fn(ArtworkComment $c) => [
|
|
||||||
'id' => $c->id,
|
'id' => $c->id,
|
||||||
'content' => (string) $c->content,
|
'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(),
|
'created_at' => $c->created_at?->toIsoString(),
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => $c->user?->id,
|
'id' => $c->user?->id,
|
||||||
'name' => $c->user?->name,
|
'name' => $c->user?->name,
|
||||||
'username' => $c->user?->username,
|
'username' => $c->user?->username,
|
||||||
|
'display' => $c->user?->username ?? $c->user?->name ?? 'User',
|
||||||
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
||||||
'avatar_url' => $c->user?->profile?->avatar_url,
|
'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()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
|||||||
@@ -54,29 +54,37 @@ class ArtworkListResource extends JsonResource
|
|||||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
||||||
: null;
|
: 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 [
|
return [
|
||||||
|
'id' => $artId,
|
||||||
'slug' => $slugVal,
|
'slug' => $slugVal,
|
||||||
'title' => $get('title'),
|
'title' => $decode($get('title')),
|
||||||
'description' => $this->when($request->boolean('include_description'), fn() => $get('description')),
|
'description' => $this->when($request->boolean('include_description'), fn() => $decode($get('description'))),
|
||||||
'dimensions' => [
|
'dimensions' => [
|
||||||
'width' => $get('width'),
|
'width' => $get('width'),
|
||||||
'height' => $get('height'),
|
'height' => $get('height'),
|
||||||
],
|
],
|
||||||
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
'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 [
|
return [
|
||||||
'name' => $this->user->name ?? null,
|
'name' => $decode($this->user->name ?? null),
|
||||||
|
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
'category' => $primaryCategory ? [
|
'category' => $primaryCategory ? [
|
||||||
'slug' => $primaryCategory->slug ?? null,
|
'slug' => $primaryCategory->slug ?? null,
|
||||||
'name' => $primaryCategory->name ?? null,
|
'name' => $decode($primaryCategory->name ?? null),
|
||||||
'content_type' => $contentTypeSlug,
|
'content_type' => $contentTypeSlug,
|
||||||
'url' => $webUrl,
|
'url' => $webUrl,
|
||||||
] : null,
|
] : null,
|
||||||
'urls' => [
|
'urls' => [
|
||||||
'web' => $webUrl,
|
'web' => $webUrl ?? $directUrl,
|
||||||
'canonical' => $webUrl,
|
'direct' => $directUrl,
|
||||||
|
'canonical' => $webUrl ?? $directUrl,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ class ArtworkResource extends JsonResource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $this->id,
|
'id' => (int) $this->id,
|
||||||
'slug' => (string) $this->slug,
|
'slug' => (string) $this->slug,
|
||||||
'title' => (string) $this->title,
|
'title' => $decode($this->title),
|
||||||
'description' => (string) ($this->description ?? ''),
|
'description' => $decode($this->description),
|
||||||
'dimensions' => [
|
'dimensions' => [
|
||||||
'width' => (int) ($this->width ?? 0),
|
'width' => (int) ($this->width ?? 0),
|
||||||
'height' => (int) ($this->height ?? 0),
|
'height' => (int) ($this->height ?? 0),
|
||||||
@@ -80,7 +82,7 @@ class ArtworkResource extends JsonResource
|
|||||||
],
|
],
|
||||||
'user' => [
|
'user' => [
|
||||||
'id' => (int) ($this->user?->id ?? 0),
|
'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 ?? ''),
|
'username' => (string) ($this->user?->username ?? ''),
|
||||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||||
@@ -102,14 +104,21 @@ class ArtworkResource extends JsonResource
|
|||||||
'categories' => $this->categories->map(fn ($category) => [
|
'categories' => $this->categories->map(fn ($category) => [
|
||||||
'id' => (int) $category->id,
|
'id' => (int) $category->id,
|
||||||
'slug' => (string) $category->slug,
|
'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 ?? ''),
|
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||||
'url' => $category->contentType ? $category->url : null,
|
'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(),
|
])->values(),
|
||||||
'tags' => $this->tags->map(fn ($tag) => [
|
'tags' => $this->tags->map(fn ($tag) => [
|
||||||
'id' => (int) $tag->id,
|
'id' => (int) $tag->id,
|
||||||
'slug' => (string) $tag->slug,
|
'slug' => (string) $tag->slug,
|
||||||
'name' => (string) $tag->name,
|
'name' => html_entity_decode((string) $tag->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
])->values(),
|
])->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ArtworkComment extends Model
|
|||||||
'legacy_id',
|
'legacy_id',
|
||||||
'artwork_id',
|
'artwork_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'parent_id',
|
||||||
'content',
|
'content',
|
||||||
'raw_content',
|
'raw_content',
|
||||||
'rendered_content',
|
'rendered_content',
|
||||||
@@ -51,6 +52,27 @@ class ArtworkComment extends Model
|
|||||||
return $this->belongsTo(User::class);
|
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
|
public function reactions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class ContentSanitizer
|
|||||||
// Suppress warnings from malformed fragments
|
// Suppress warnings from malformed fragments
|
||||||
libxml_use_internal_errors(true);
|
libxml_use_internal_errors(true);
|
||||||
$doc->loadHTML(
|
$doc->loadHTML(
|
||||||
'<html><body>' . $html . '</body></html>',
|
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>',
|
||||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||||
);
|
);
|
||||||
libxml_clear_errors();
|
libxml_clear_errors();
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ final class UserStatsService
|
|||||||
DB::table('user_statistics')
|
DB::table('user_statistics')
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->update([
|
->update([
|
||||||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
|
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -264,7 +264,7 @@ final class UserStatsService
|
|||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->where($column, '>', 0)
|
->where($column, '>', 0)
|
||||||
->update([
|
->update([
|
||||||
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
|
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class AvatarUrl
|
|||||||
{
|
{
|
||||||
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
$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
|
private static function resolveHash(int $userId): ?string
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('artwork_comments', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import axios from 'axios'
|
||||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||||
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
||||||
import ArtworkActions from '../components/artwork/ArtworkActions'
|
|
||||||
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
||||||
import ArtworkStats from '../components/artwork/ArtworkStats'
|
|
||||||
import ArtworkTags from '../components/artwork/ArtworkTags'
|
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 ArtworkDescription from '../components/artwork/ArtworkDescription'
|
||||||
import ArtworkComments from '../components/artwork/ArtworkComments'
|
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 ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
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 = [] }) {
|
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)
|
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
|
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
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.
|
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||||
@@ -66,7 +77,9 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
<main className="pb-24 pt-6 lg:pb-12 lg:pt-8">
|
||||||
|
{/* ── Hero ────────────────────────────────────────────────────── */}
|
||||||
|
<div id="artwork-hero-anchor" className="mx-auto w-full max-w-screen-2xl px-3 sm:px-6 lg:px-8">
|
||||||
<ArtworkHero
|
<ArtworkHero
|
||||||
artwork={artwork}
|
artwork={artwork}
|
||||||
presentMd={presentMd}
|
presentMd={presentMd}
|
||||||
@@ -78,20 +91,43 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
|||||||
onPrev={navState.navigatePrev}
|
onPrev={navState.navigatePrev}
|
||||||
onNext={navState.navigateNext}
|
onNext={navState.navigateNext}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-6 space-y-4 lg:hidden">
|
|
||||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
|
||||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority onStatsChange={handleStatsChange} />
|
|
||||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
{/* ── Centered action bar with stat counts ────────────────────── */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="mx-auto mt-5 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<ArtworkActionBar
|
||||||
|
artwork={artwork}
|
||||||
|
stats={liveStats}
|
||||||
|
canonicalUrl={canonicalUrl}
|
||||||
|
onStatsChange={handleStatsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Two-column content ──────────────────────────────────────── */}
|
||||||
|
<div className="mx-auto mt-8 w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
|
{/* LEFT COLUMN — main content */}
|
||||||
|
<div className="relative z-10 min-w-0 space-y-5">
|
||||||
|
{/* Title + author + breadcrumbs */}
|
||||||
<ArtworkMeta artwork={artwork} />
|
<ArtworkMeta artwork={artwork} />
|
||||||
<ArtworkStats artwork={artwork} stats={liveStats} />
|
|
||||||
<ArtworkTags artwork={artwork} />
|
{/* Description */}
|
||||||
<ArtworkDescription artwork={artwork} />
|
<ArtworkDescription artwork={artwork} />
|
||||||
<ArtworkReactions artworkId={artwork.id} isLoggedIn={isAuthenticated} />
|
|
||||||
|
{/* Artwork reactions */}
|
||||||
|
{reactionTotals !== null && (
|
||||||
|
<ReactionBar
|
||||||
|
entityType="artwork"
|
||||||
|
entityId={artwork.id}
|
||||||
|
initialTotals={reactionTotals}
|
||||||
|
isLoggedIn={isAuthenticated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags & categories */}
|
||||||
|
<ArtworkTags artwork={artwork} />
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
<ArtworkComments
|
<ArtworkComments
|
||||||
artworkId={artwork.id}
|
artworkId={artwork.id}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
@@ -100,16 +136,24 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="hidden space-y-6 lg:block">
|
{/* RIGHT COLUMN — sidebar */}
|
||||||
<div className="sticky top-24 space-y-4">
|
<aside className="space-y-5 lg:sticky lg:top-6 lg:self-start">
|
||||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
{/* Creator card */}
|
||||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} onStatsChange={handleStatsChange} />
|
<CreatorSpotlight artwork={artwork} presentSq={presentSq} related={related} />
|
||||||
|
|
||||||
|
{/* Details (collapsible) */}
|
||||||
|
<ArtworkDetailsPanel artwork={artwork} stats={liveStats} />
|
||||||
|
|
||||||
|
{/* Awards */}
|
||||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ArtworkRelated related={related} />
|
{/* ── Full-width recommendation rails ─────────────────────────── */}
|
||||||
|
<div className="mt-14 w-full max-w-screen-2xl mx-auto">
|
||||||
|
<ArtworkRecommendationsRails artwork={artwork} related={related} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
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 }) {
|
function ArtCard({ item }) {
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
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 }) {
|
function CreatorCard({ creator }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
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 }) {
|
function FreshCard({ item }) {
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
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 }) {
|
function ArtCard({ item }) {
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
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 }) {
|
function CreatorCard({ creator }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
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 }) {
|
function ArtCard({ item }) {
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
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 }) {
|
function ArtCard({ item }) {
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
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 }) {
|
export default function HomeWelcomeRow({ user_data }) {
|
||||||
if (!user_data) return null
|
if (!user_data) return null
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import SearchBar from '../Search/SearchBar'
|
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 }) {
|
export default function Topbar({ user = null }) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|||||||
324
resources/js/components/artwork/ArtworkActionBar.jsx
Normal file
324
resources/js/components/artwork/ArtworkActionBar.jsx
Normal file
@@ -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 ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||||
|
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkIcon({ filled }) {
|
||||||
|
return filled ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||||
|
<path fillRule="evenodd" d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloudDownIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadArrowIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlagIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ────────────────────────────────────── */}
|
||||||
|
<div className="hidden lg:flex lg:items-center lg:justify-center lg:gap-3">
|
||||||
|
{/* Like stat pill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={liked ? 'Unlike artwork' : 'Like artwork'}
|
||||||
|
onClick={onToggleLike}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
|
liked
|
||||||
|
? 'border-rose-500/40 bg-rose-500/15 text-rose-400 shadow-lg shadow-rose-500/10 hover:bg-rose-500/20'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<HeartIcon filled={liked} />
|
||||||
|
<span className="tabular-nums">{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Favorite/bookmark stat pill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={favorited ? 'Unsave artwork' : 'Save artwork'}
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
|
favorited
|
||||||
|
? 'border-amber-500/40 bg-amber-500/15 text-amber-400 shadow-lg shadow-amber-500/10 hover:bg-amber-500/20'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<BookmarkIcon filled={favorited} />
|
||||||
|
<span className="tabular-nums">{favCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Views stat pill */}
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
|
||||||
|
<CloudDownIcon />
|
||||||
|
<span className="tabular-nums">{viewCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share pill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Share artwork"
|
||||||
|
onClick={onShare}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||||
|
>
|
||||||
|
<ShareIcon />
|
||||||
|
{copied ? 'Copied!' : 'Share'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Report pill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Report artwork"
|
||||||
|
onClick={onReport}
|
||||||
|
disabled={reporting}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FlagIcon />
|
||||||
|
{reporting ? '…' : 'Report'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Download artwork"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-2.5 text-sm font-bold text-deep shadow-lg shadow-accent/25 transition-all duration-200 hover:brightness-110 hover:shadow-xl hover:shadow-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<DownloadArrowIcon />
|
||||||
|
{downloading ? 'Downloading…' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mobile fixed bottom bar ─────────────────────────────────── */}
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/[0.08] bg-nova-900/95 px-3 py-2.5 backdrop-blur-md lg:hidden">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={liked ? 'Unlike' : 'Like'}
|
||||||
|
onClick={onToggleLike}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||||
|
liked
|
||||||
|
? 'border-rose-500/40 bg-rose-500/15 text-rose-400'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<HeartIcon filled={liked} />
|
||||||
|
<span className="tabular-nums">{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={favorited ? 'Unsave' : 'Save'}
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||||
|
favorited
|
||||||
|
? 'border-amber-500/40 bg-amber-500/15 text-amber-400'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<BookmarkIcon filled={favorited} />
|
||||||
|
<span className="tabular-nums">{favCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Share */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Share"
|
||||||
|
onClick={onShare}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all"
|
||||||
|
>
|
||||||
|
<ShareIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Report */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Report"
|
||||||
|
onClick={onReport}
|
||||||
|
disabled={reporting}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-xs font-medium text-white/70 transition-all hover:border-red-500/40 hover:text-red-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FlagIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Download artwork"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-accent px-5 py-2 text-xs font-bold text-deep transition hover:brightness-110 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<DownloadArrowIcon />
|
||||||
|
{downloading ? '…' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -136,8 +136,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
|||||||
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Awards</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/30">Awards</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-2 text-xs text-red-400">{error}</p>
|
<p className="mt-2 text-xs text-red-400">{error}</p>
|
||||||
@@ -158,8 +158,8 @@ export default function ArtworkAwards({ artwork, initialAwards = null, isAuthent
|
|||||||
className={[
|
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',
|
'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
|
isActive
|
||||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
? 'border-accent/40 bg-accent/10 font-semibold text-accent shadow-lg shadow-accent/10'
|
||||||
: 'border-nova-600 text-white hover:bg-nova-800',
|
: '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',
|
(!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
33
resources/js/components/artwork/ArtworkCardMini.jsx
Normal file
33
resources/js/components/artwork/ArtworkCardMini.jsx
Normal file
@@ -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 (
|
||||||
|
<article className="group min-w-[14rem] shrink-0 snap-start overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 hover:-translate-y-0.5 hover:border-white/[0.1] hover:shadow-xl hover:shadow-black/30">
|
||||||
|
<a href={item.url} className="block">
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden bg-deep">
|
||||||
|
<img
|
||||||
|
src={item.thumb || FALLBACK_MD}
|
||||||
|
srcSet={item.thumbSrcSet || undefined}
|
||||||
|
sizes="256px"
|
||||||
|
alt={item.title || 'Artwork'}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.src = FALLBACK_MD
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-60" />
|
||||||
|
</div>
|
||||||
|
<div className="px-3.5 py-3">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-white/90">{item.title || 'Untitled'}</h3>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-white/40">by {item.author || 'Artist'}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,32 @@ function timeAgo(dateStr) {
|
|||||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Icons ─────────────────────────────────────────────────────────────────── */
|
||||||
|
function ReplyIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||||
|
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubbleIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronDownIcon({ className }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||||
|
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Avatar ─────────────────────────────────────────────────────────────────── */
|
||||||
function Avatar({ user, size = 36 }) {
|
function Avatar({ user, size = 36 }) {
|
||||||
if (user?.avatar_url) {
|
if (user?.avatar_url) {
|
||||||
return (
|
return (
|
||||||
@@ -28,12 +54,12 @@ function Avatar({ user, size = 36 }) {
|
|||||||
alt={user.name || user.username || ''}
|
alt={user.name || user.username || ''}
|
||||||
width={size}
|
width={size}
|
||||||
height={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 }}
|
style={{ width: size, height: size }}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.onerror = null
|
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()
|
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
|
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
@@ -49,21 +75,172 @@ function Avatar({ user, size = 36 }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single comment ────────────────────────────────────────────────────────────
|
// ── Reply item (nested under a parent) ────────────────────────────────────────
|
||||||
|
|
||||||
function CommentItem({ comment, isLoggedIn }) {
|
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 || []
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
{user?.profile_url ? (
|
||||||
|
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
|
||||||
|
<Avatar user={user} size={avatarSize} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{user?.profile_url ? (
|
||||||
|
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
|
||||||
|
{profileLabel}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-white/15" aria-hidden="true">·</span>
|
||||||
|
<time
|
||||||
|
dateTime={reply.created_at}
|
||||||
|
title={reply.created_at ? new Date(reply.created_at).toLocaleString() : ''}
|
||||||
|
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||||
|
>
|
||||||
|
{reply.time_ago || timeAgo(reply.created_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{html ? (
|
||||||
|
<div
|
||||||
|
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions — Reply + React inline */}
|
||||||
|
<div className="flex items-center gap-1.5 pt-1">
|
||||||
|
{isLoggedIn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReplyForm(v => !v)}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||||
|
showReplyForm
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<ReplyIcon />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactionBar
|
||||||
|
entityType="comment"
|
||||||
|
entityId={reply.id}
|
||||||
|
initialTotals={reactionTotals}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline reply form */}
|
||||||
|
{showReplyForm && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CommentForm
|
||||||
|
artworkId={artworkId}
|
||||||
|
parentId={reply.id}
|
||||||
|
replyTo={profileLabel}
|
||||||
|
onCancelReply={() => setShowReplyForm(false)}
|
||||||
|
onPosted={handleReplyPosted}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nested replies (tree) */}
|
||||||
|
{replies.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
|
||||||
|
{visibleReplies.map((child) => (
|
||||||
|
<ReplyItem
|
||||||
|
key={child.id}
|
||||||
|
reply={child}
|
||||||
|
parentId={reply.id}
|
||||||
|
artworkId={artworkId}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
onReplyPosted={onReplyPosted}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllReplies(true)}
|
||||||
|
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="h-3 w-3" />
|
||||||
|
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single comment (top-level) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||||
const user = comment.user
|
const user = comment.user
|
||||||
const html = comment.rendered_content ?? null
|
const html = comment.rendered_content ?? null
|
||||||
const plain = comment.content ?? ''
|
const plain = comment.content ?? comment.raw_content ?? ''
|
||||||
|
const profileLabel = user?.display || user?.username || user?.name || 'Member'
|
||||||
|
const replies = comment.replies || []
|
||||||
|
|
||||||
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
|
|
||||||
const flood = isFlood(plain)
|
const flood = isFlood(plain)
|
||||||
const [expanded, setExpanded] = useState(!flood)
|
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 ?? {})
|
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
|
||||||
|
|
||||||
// Load reactions lazily if not provided
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (comment.reactions || !comment.id) return
|
if (comment.reactions || !comment.id) return
|
||||||
axios
|
axios
|
||||||
@@ -72,92 +249,159 @@ function CommentItem({ comment, isLoggedIn }) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [comment.id, comment.reactions])
|
}, [comment.id, comment.reactions])
|
||||||
|
|
||||||
|
const handleReplyPosted = useCallback((newReply) => {
|
||||||
|
onReplyPosted?.(comment.id, newReply)
|
||||||
|
setShowReplyForm(false)
|
||||||
|
setShowAllReplies(true)
|
||||||
|
}, [comment.id, onReplyPosted])
|
||||||
|
|
||||||
|
// Show first 2 replies by default, expand to show all
|
||||||
|
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||||
|
const hiddenReplyCount = replies.length - 2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex gap-3" id={`comment-${comment.id}`}>
|
<li
|
||||||
|
id={`comment-${comment.id}`}
|
||||||
|
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
|
||||||
|
>
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
<div className="flex gap-3.5">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{user?.profile_url ? (
|
{user?.profile_url ? (
|
||||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||||
<Avatar user={user} size={36} />
|
<Avatar user={user} size={38} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="shrink-0 mt-0.5">
|
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
|
||||||
<Avatar user={user} size={36} />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{user?.profile_url ? (
|
{user?.profile_url ? (
|
||||||
<a href={user.profile_url} className="text-sm font-medium text-white hover:underline">
|
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
|
||||||
{user.display || user.username || user.name || 'Member'}
|
{profileLabel}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium text-white">
|
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||||
{user?.display || user?.username || user?.name || 'Member'}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
<span className="text-white/15" aria-hidden="true">·</span>
|
||||||
<time
|
<time
|
||||||
dateTime={comment.created_at}
|
dateTime={comment.created_at}
|
||||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||||
className="text-xs text-neutral-500"
|
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||||
>
|
>
|
||||||
{comment.time_ago || timeAgo(comment.created_at)}
|
{comment.time_ago || timeAgo(comment.created_at)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
|
{/* Body */}
|
||||||
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
|
|
||||||
<div
|
<div
|
||||||
className={!expanded ? 'overflow-hidden relative' : undefined}
|
className={!expanded ? 'overflow-hidden relative' : undefined}
|
||||||
style={!expanded ? { maxHeight: '5em' } : undefined}
|
style={!expanded ? { maxHeight: '5em' } : undefined}
|
||||||
>
|
>
|
||||||
{html ? (
|
{html ? (
|
||||||
<div
|
<div
|
||||||
className="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
|
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
|
||||||
prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline
|
|
||||||
prose-code:bg-white/[0.07] prose-code:px-1 prose-code:rounded prose-code:text-xs"
|
|
||||||
// rendered_content is server-sanitized HTML — safe to inject
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
|
||||||
{plain}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gradient fade at the bottom while collapsed */}
|
|
||||||
{flood && !expanded && (
|
{flood && !expanded && (
|
||||||
<div
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
|
||||||
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flood expand / collapse toggle */}
|
|
||||||
{flood && (
|
{flood && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((e) => !e)}
|
onClick={() => setExpanded((e) => !e)}
|
||||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-500 rounded"
|
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
|
{expanded ? '↑ Collapse' : '↓ Show full comment'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1.5 pt-0.5">
|
||||||
|
{isLoggedIn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReplyForm(v => !v)}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||||
|
showReplyForm
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<ReplyIcon />
|
||||||
|
Reply
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reactions */}
|
|
||||||
{Object.keys(reactionTotals).length > 0 && (
|
|
||||||
<ReactionBar
|
<ReactionBar
|
||||||
entityType="comment"
|
entityType="comment"
|
||||||
entityId={comment.id}
|
entityId={comment.id}
|
||||||
initialTotals={reactionTotals}
|
initialTotals={reactionTotals}
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={isLoggedIn}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Replies thread ───────────────────────────────────────────────── */}
|
||||||
|
{(replies.length > 0 || showReplyForm) && (
|
||||||
|
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
|
||||||
|
{replies.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
|
||||||
|
{visibleReplies.map((reply) => (
|
||||||
|
<ReplyItem
|
||||||
|
key={reply.id}
|
||||||
|
reply={reply}
|
||||||
|
parentId={comment.id}
|
||||||
|
artworkId={artworkId}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
onReplyPosted={onReplyPosted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!showAllReplies && hiddenReplyCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllReplies(true)}
|
||||||
|
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||||
|
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline reply form */}
|
||||||
|
{showReplyForm && (
|
||||||
|
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
|
||||||
|
<CommentForm
|
||||||
|
artworkId={artworkId}
|
||||||
|
parentId={comment.id}
|
||||||
|
replyTo={profileLabel}
|
||||||
|
onCancelReply={() => setShowReplyForm(false)}
|
||||||
|
onPosted={handleReplyPosted}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -166,14 +410,24 @@ function CommentItem({ comment, isLoggedIn }) {
|
|||||||
|
|
||||||
function Skeleton() {
|
function Skeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-pulse">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<div
|
||||||
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
|
key={i}
|
||||||
<div className="flex-1 space-y-2 pt-1">
|
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
|
||||||
<div className="h-3 bg-white/[0.07] rounded w-28" />
|
style={{ animationDelay: `${i * 120}ms` }}
|
||||||
<div className="h-3 bg-white/[0.05] rounded w-full" />
|
>
|
||||||
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
|
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
|
||||||
|
<div className="flex-1 space-y-3 pt-1">
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
|
||||||
|
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
|
||||||
|
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
|
||||||
|
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -183,19 +437,6 @@ function Skeleton() {
|
|||||||
|
|
||||||
// ── Main export ───────────────────────────────────────────────────────────────
|
// ── 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({
|
export default function ArtworkComments({
|
||||||
artworkId,
|
artworkId,
|
||||||
comments: initialComments = [],
|
comments: initialComments = [],
|
||||||
@@ -209,7 +450,6 @@ export default function ArtworkComments({
|
|||||||
const [total, setTotal] = useState(initialComments.length)
|
const [total, setTotal] = useState(initialComments.length)
|
||||||
const initialized = useRef(false)
|
const initialized = useRef(false)
|
||||||
|
|
||||||
// Load comments from API
|
|
||||||
const loadComments = useCallback(
|
const loadComments = useCallback(
|
||||||
async (p = 1) => {
|
async (p = 1) => {
|
||||||
if (!artworkId) return
|
if (!artworkId) return
|
||||||
@@ -225,7 +465,7 @@ export default function ArtworkComments({
|
|||||||
setLastPage(data.meta?.last_page ?? 1)
|
setLastPage(data.meta?.last_page ?? 1)
|
||||||
setTotal(data.meta?.total ?? 0)
|
setTotal(data.meta?.total ?? 0)
|
||||||
} catch {
|
} catch {
|
||||||
// keep existing data on error
|
// keep existing
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -233,7 +473,6 @@ export default function ArtworkComments({
|
|||||||
[artworkId],
|
[artworkId],
|
||||||
)
|
)
|
||||||
|
|
||||||
// On mount, load if artworkId provided and no SSR comments given
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized.current) return
|
if (initialized.current) return
|
||||||
initialized.current = true
|
initialized.current = true
|
||||||
@@ -245,21 +484,95 @@ export default function ArtworkComments({
|
|||||||
}
|
}
|
||||||
}, [artworkId, initialComments.length, loadComments])
|
}, [artworkId, initialComments.length, loadComments])
|
||||||
|
|
||||||
|
// New top-level comment posted
|
||||||
const handlePosted = useCallback((newComment) => {
|
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)
|
setTotal((t) => t + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Comments" className="space-y-6">
|
<section aria-label="Comments" className="space-y-6">
|
||||||
<h2 className="text-base font-semibold text-white">
|
{/* Section header */}
|
||||||
Comments{' '}
|
<div className="flex items-center gap-3">
|
||||||
{total > 0 && (
|
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
|
||||||
<span className="text-neutral-500 font-normal">({total})</span>
|
Comments
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Comment form */}
|
{/* Comment list */}
|
||||||
|
{loading && comments.length === 0 ? (
|
||||||
|
<Skeleton />
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
|
||||||
|
<ChatBubbleIcon />
|
||||||
|
<p className="text-sm font-medium text-white/40">No comments yet</p>
|
||||||
|
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="space-y-3 sm:space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
artworkId={artworkId}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
onReplyPosted={handleReplyPosted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{page < lastPage && (
|
||||||
|
<div className="flex justify-center pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadComments(page + 1)}
|
||||||
|
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Load more comments'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment form — after all comments */}
|
||||||
{artworkId && (
|
{artworkId && (
|
||||||
<CommentForm
|
<CommentForm
|
||||||
artworkId={artworkId}
|
artworkId={artworkId}
|
||||||
@@ -268,39 +581,6 @@ export default function ArtworkComments({
|
|||||||
loginUrl={loginUrl}
|
loginUrl={loginUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comment list */}
|
|
||||||
{loading && comments.length === 0 ? (
|
|
||||||
<Skeleton />
|
|
||||||
) : comments.length === 0 ? (
|
|
||||||
<p className="text-sm text-neutral-500">No comments yet. Be the first!</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ul className="space-y-5">
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<CommentItem
|
|
||||||
key={comment.id}
|
|
||||||
comment={comment}
|
|
||||||
isLoggedIn={isLoggedIn}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Load more */}
|
|
||||||
{page < lastPage && (
|
|
||||||
<div className="flex justify-center pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => loadComments(page + 1)}
|
|
||||||
className="px-5 py-2 rounded-lg text-sm text-white/60 border border-white/[0.08] hover:text-white hover:border-white/20 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{loading ? 'Loading…' : 'Load more comments'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function renderMarkdownSafe(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p key={`p-${lineIndex}`} className="text-base leading-7 text-soft">
|
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
|
||||||
{parts}
|
{parts}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
@@ -60,19 +60,18 @@ export default function ArtworkDescription({ artwork }) {
|
|||||||
if (content.length === 0) return null
|
if (content.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30">
|
<div>
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Description</h2>
|
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
|
||||||
<div className="mt-4 max-w-[720px] space-y-4">{rendered}</div>
|
|
||||||
|
|
||||||
{content.length > COLLAPSE_AT && (
|
{content.length > COLLAPSE_AT && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-4 text-sm font-medium text-accent hover:underline"
|
className="mt-3 text-sm font-medium text-accent transition-colors hover:text-accent/80"
|
||||||
onClick={() => setExpanded((value) => !value)}
|
onClick={() => setExpanded((value) => !value)}
|
||||||
>
|
>
|
||||||
{expanded ? 'Show less' : 'Show more'}
|
{expanded ? 'Show less' : 'Show more'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
91
resources/js/components/artwork/ArtworkDetailsDrawer.jsx
Normal file
91
resources/js/components/artwork/ArtworkDetailsDrawer.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-[70]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close details"
|
||||||
|
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 max-h-[90vh] overflow-y-auto rounded-t-3xl border border-white/10 bg-nova-900/85 p-5 backdrop-blur xl:inset-auto xl:right-6 xl:top-24 xl:w-[34rem] xl:rounded-3xl xl:border-white/15 xl:p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-white">Details</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close details drawer"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/15 p-4">
|
||||||
|
<ArtworkBreadcrumbs artwork={artwork} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">Resolution</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">Upload date</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{formatDate(artwork?.published_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">File type</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{fileType}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">Views</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{formatCount(stats?.views)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">Downloads</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{formatCount(stats?.downloads)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5">
|
||||||
|
<dt className="text-soft">Favorites</dt>
|
||||||
|
<dd className="mt-1 font-medium text-white">{formatCount(stats?.favorites)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
resources/js/components/artwork/ArtworkDetailsPanel.jsx
Normal file
84
resources/js/components/artwork/ArtworkDetailsPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] px-3 py-3.5">
|
||||||
|
<span className="text-white/30">{icon}</span>
|
||||||
|
<span className="text-base font-semibold tabular-nums text-white/90">{value}</span>
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-white/35">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key-value row ─────────────────────────────────────────────────────── */
|
||||||
|
function InfoRow({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-xs uppercase tracking-wider text-white/35">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-white/80">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<StatTile
|
||||||
|
icon={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label="Views"
|
||||||
|
value={formatCount(stats?.views)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4.5 w-4.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
label="Downloads"
|
||||||
|
value={formatCount(stats?.downloads)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info rows */}
|
||||||
|
<div className="mt-4 divide-y divide-white/[0.05]">
|
||||||
|
{resolution && <InfoRow label="Resolution" value={resolution} />}
|
||||||
|
<InfoRow label="Uploaded" value={formatDate(artwork?.published_at)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,23 +18,38 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
|
||||||
const blurBackdropSrc = mdSource || lgSource || xlSource || null
|
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`
|
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure className="w-full">
|
<figure className="relative w-full overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-b from-nova-950 via-nova-900 to-nova-900 p-2 shadow-[0_35px_90px_-35px_rgba(15,23,36,0.9)] sm:p-4">
|
||||||
<div className="relative mx-auto w-full max-w-[1280px]">
|
{blurBackdropSrc && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={blurBackdropSrc}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover opacity-30 blur-3xl"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-nova-950/55 via-nova-900/40 to-nova-950/70" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 backdrop-blur-sm" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Outer flex row: left arrow | image | right arrow */}
|
<div className="relative mx-auto flex w-full max-w-[1400px] items-center gap-2 sm:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||||
|
|
||||||
{/* Prev arrow — outside the picture */}
|
|
||||||
<div className="flex w-12 shrink-0 justify-center">
|
|
||||||
{hasPrev && (
|
{hasPrev && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Previous artwork"
|
aria-label="Previous artwork"
|
||||||
onClick={() => onPrev?.()}
|
onClick={() => onPrev?.()}
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
|
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
@@ -43,36 +58,39 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image area */}
|
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
{hasRealArtworkImage && (
|
|
||||||
<div className="absolute inset-0 -z-10" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
className={`relative mx-auto w-full max-h-[70vh] overflow-hidden ] ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||||
|
style={{ aspectRatio }}
|
||||||
onClick={onOpenViewer}
|
onClick={onOpenViewer}
|
||||||
role={onOpenViewer ? 'button' : undefined}
|
role={onOpenViewer ? 'button' : undefined}
|
||||||
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
aria-label={onOpenViewer ? 'Open artwork lightbox' : undefined}
|
||||||
tabIndex={onOpenViewer ? 0 : undefined}
|
tabIndex={onOpenViewer ? 0 : undefined}
|
||||||
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
onKeyDown={onOpenViewer ? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onOpenViewer()
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={md}
|
src={md}
|
||||||
alt={artwork?.title ?? 'Artwork'}
|
alt={artwork?.title ?? 'Artwork'}
|
||||||
className="absolute inset-0 h-full w-full object-contain"
|
className="absolute inset-0 h-full w-full object-contain rounded-xl"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchPriority="high"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={lg}
|
src={lg}
|
||||||
srcSet={srcSet}
|
srcSet={srcSet}
|
||||||
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
|
sizes="(min-width: 1536px) 1400px, (min-width: 1024px) 92vw, 100vw"
|
||||||
alt={artwork?.title ?? 'Artwork'}
|
alt={artwork?.title ?? 'Artwork'}
|
||||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchPriority="high"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
event.currentTarget.src = FALLBACK_LG
|
event.currentTarget.src = FALLBACK_LG
|
||||||
@@ -84,7 +102,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="View fullscreen"
|
aria-label="View fullscreen"
|
||||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 shadow-lg ring-1 ring-white/15 backdrop-blur-sm opacity-0 transition-opacity duration-150 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:opacity-100 [div:hover_&]:opacity-100"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||||
@@ -98,14 +116,13 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next arrow — outside the picture */}
|
<div className="hidden w-12 shrink-0 justify-center sm:flex">
|
||||||
<div className="flex w-12 shrink-0 justify-center">
|
|
||||||
{hasNext && (
|
{hasNext && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Next artwork"
|
aria-label="Next artwork"
|
||||||
onClick={() => onNext?.()}
|
onClick={() => onNext?.()}
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
|
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg transition-colors duration-150 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
@@ -113,8 +130,6 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,31 +2,12 @@ import React from 'react'
|
|||||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||||
|
|
||||||
export default function ArtworkMeta({ artwork }) {
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-nova-700 bg-panel p-5">
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">{artwork?.title}</h1>
|
||||||
|
<div className="mt-3">
|
||||||
<ArtworkBreadcrumbs artwork={artwork} />
|
<ArtworkBreadcrumbs artwork={artwork} />
|
||||||
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
|
||||||
<dt>Author</dt>
|
|
||||||
<dd className="text-white">{author}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
|
||||||
<dt>Upload date</dt>
|
|
||||||
<dd className="text-white">{publishedAt}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2">
|
|
||||||
<dt>Resolution</dt>
|
|
||||||
<dd className="text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ export default function ArtworkReactions({ artworkId, isLoggedIn = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4">
|
||||||
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-white/30">Reactions</h2>
|
||||||
<ReactionBar
|
<ReactionBar
|
||||||
entityType="artwork"
|
entityType="artwork"
|
||||||
entityId={artworkId}
|
entityId={artworkId}
|
||||||
initialTotals={totals}
|
initialTotals={totals}
|
||||||
isLoggedIn={isLoggedIn}
|
isLoggedIn={isLoggedIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
365
resources/js/components/artwork/ArtworkRecommendationsRails.jsx
Normal file
@@ -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 (
|
||||||
|
<article className="w-[240px] shrink-0 snap-start sm:w-[220px] lg:w-[200px] xl:w-[210px] 2xl:w-[220px]">
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden bg-neutral-900">
|
||||||
|
{/* Gloss sheen */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={item.thumb || FALLBACK}
|
||||||
|
srcSet={item.thumbSrcSet || undefined}
|
||||||
|
sizes="220px"
|
||||||
|
alt={item.title || 'Artwork'}
|
||||||
|
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom info overlay */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||||
|
<img
|
||||||
|
src={item.authorAvatar || AVATAR_FALLBACK}
|
||||||
|
alt={item.author}
|
||||||
|
className="w-5 h-5 rounded-full object-cover shrink-0 ring-1 ring-white/20"
|
||||||
|
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.author}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{item.title} by {item.author}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll arrow button ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function ScrollBtn({ direction, onClick, visible }) {
|
||||||
|
if (!visible) return null
|
||||||
|
const isLeft = direction === 'left'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={`Scroll ${direction}`}
|
||||||
|
className={`absolute top-1/2 z-30 -translate-y-1/2 hidden lg:flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/10 backdrop-blur-md transition hover:bg-black/80 ${isLeft ? 'left-2' : 'right-2'}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
{isLeft
|
||||||
|
? <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
: <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{emoji && <span className="mr-1.5">{emoji}</span>}{title}
|
||||||
|
</h2>
|
||||||
|
{seeAllHref && (
|
||||||
|
<a href={seeAllHref} className="text-sm text-nova-300 hover:text-white transition">
|
||||||
|
See all →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Permanent edge fades for infinite illusion */}
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 w-24 bg-gradient-to-r from-[#0F1724] to-transparent" />
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-0 z-20 w-24 bg-gradient-to-l from-[#0F1724] to-transparent" />
|
||||||
|
|
||||||
|
<ScrollBtn direction="left" onClick={() => scroll('left')} visible={true} />
|
||||||
|
<ScrollBtn direction="right" onClick={() => scroll('right')} visible={true} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex gap-4 overflow-x-auto px-4 pb-3 sm:px-6 lg:px-8 scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{loopItems.map((item, idx) => (
|
||||||
|
<RailCard key={`${item.id || item.url}-${idx}`} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<div className="space-y-14">
|
||||||
|
<Rail title="Similar Artworks" emoji="✨" items={similarItems} />
|
||||||
|
<Rail title={trendingLabel} emoji="🔥" items={trendingRailItems} seeAllHref={trendingHref} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,19 +4,52 @@ export default function ArtworkTags({ artwork }) {
|
|||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const categories = (artwork?.categories || []).map((category) => ({
|
const seen = new Set()
|
||||||
key: `cat-${category.id || category.slug}`,
|
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,
|
label: category.name,
|
||||||
href: category.url || `/${category.content_type_slug}/${category.slug}`,
|
href: category.url || `/${category.content_type_slug}/${category.slug}`,
|
||||||
}))
|
isCategory: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||||
key: `tag-${tag.id || tag.slug}`,
|
key: `tag-${tag.id || tag.slug}`,
|
||||||
label: tag.name,
|
label: tag.name,
|
||||||
href: `/tag/${tag.slug || ''}`,
|
href: `/tag/${tag.slug || ''}`,
|
||||||
|
isCategory: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...categories, ...artworkTags]
|
return [...categoryPills, ...artworkTags]
|
||||||
}, [artwork])
|
}, [artwork])
|
||||||
|
|
||||||
if (tags.length === 0) return null
|
if (tags.length === 0) return null
|
||||||
@@ -24,31 +57,34 @@ export default function ArtworkTags({ artwork }) {
|
|||||||
const visible = expanded ? tags : tags.slice(0, 12)
|
const visible = expanded ? tags : tags.slice(0, 12)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-nova-700 bg-panel p-5">
|
<div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-accent/70">Tags & Categories</h3>
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Tags & Categories</h2>
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.length > 12 && (
|
{visible.map((tag, idx) => (
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-accent hover:underline"
|
|
||||||
onClick={() => setExpanded((value) => !value)}
|
|
||||||
>
|
|
||||||
{expanded ? 'Show less' : `Show all (${tags.length})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{visible.map((tag) => (
|
|
||||||
<a
|
<a
|
||||||
key={tag.key}
|
key={tag.key}
|
||||||
href={tag.href}
|
href={tag.href}
|
||||||
className="inline-flex items-center rounded-full border border-nova-600 bg-nova-900/30 px-3 py-1 text-xs text-white hover:border-accent hover:text-accent"
|
className={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200',
|
||||||
|
tag.isCategory
|
||||||
|
? 'border-accent/30 bg-accent/10 text-accent hover:border-accent/50 hover:bg-accent/20'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.03] text-white/60 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80',
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{tags.length > 12 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-full border border-dashed border-white/[0.1] px-3 py-1.5 text-xs text-white/40 transition hover:border-white/20 hover:text-white/60"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Show less' : `+${tags.length - 12} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
165
resources/js/components/artwork/CreatorSpotlight.jsx
Normal file
165
resources/js/components/artwork/CreatorSpotlight.jsx
Normal file
@@ -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 (
|
||||||
|
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||||
|
{/* Avatar + info — stacked for sidebar */}
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<a href={profileUrl} className="group">
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt={authorName}
|
||||||
|
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.src = AVATAR_FALLBACK
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||||
|
{authorName}
|
||||||
|
</a>
|
||||||
|
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||||
|
<p className="mt-1 text-xs font-medium text-white/30">
|
||||||
|
{followersCount.toLocaleString()} Followers
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Follow + Profile buttons */}
|
||||||
|
<div className="mt-4 flex w-full gap-2">
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
|
</svg>
|
||||||
|
Follow
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||||
|
onClick={onToggleFollow}
|
||||||
|
className={[
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||||
|
following
|
||||||
|
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||||
|
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
{following ? 'Following' : 'Follow'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More from creator rail */}
|
||||||
|
{creatorItems.length > 0 && (
|
||||||
|
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||||
|
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||||
|
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||||
|
<div className="aspect-square overflow-hidden bg-deep">
|
||||||
|
<img
|
||||||
|
src={item.thumb || AVATAR_FALLBACK}
|
||||||
|
alt={item.title || 'Artwork'}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||||
|
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||||
|
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||||
|
{item.likes ? formatCount(item.likes) : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,181 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
import EmojiPickerButton from './EmojiPickerButton'
|
import EmojiPickerButton from './EmojiPickerButton'
|
||||||
|
|
||||||
/**
|
/* ── Toolbar icon components ──────────────────────────────────────────────── */
|
||||||
* Comment form with emoji picker and Markdown-lite support.
|
function BoldIcon() {
|
||||||
*
|
return (
|
||||||
* Props:
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||||
* artworkId number Target artwork
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
|
||||||
* onPosted (comment) => void Called when comment is successfully posted
|
</svg>
|
||||||
* isLoggedIn boolean
|
)
|
||||||
* loginUrl string Where to redirect non-authenticated users
|
}
|
||||||
*/
|
|
||||||
|
function ItalicIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||||
|
<line x1="19" y1="4" x2="10" y2="4" />
|
||||||
|
<line x1="14" y1="20" x2="5" y2="20" />
|
||||||
|
<line x1="15" y1="4" x2="9" y2="20" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||||
|
<polyline points="16 18 22 12 16 6" />
|
||||||
|
<polyline points="8 6 2 12 8 18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoteIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||||
|
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar button wrapper ───────────────────────────────────────────────── */
|
||||||
|
function ToolbarBtn({ title, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); onClick() }}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main component ───────────────────────────────────────────────────────── */
|
||||||
export default function CommentForm({
|
export default function CommentForm({
|
||||||
artworkId,
|
artworkId,
|
||||||
onPosted,
|
onPosted,
|
||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
loginUrl = '/login',
|
loginUrl = '/login',
|
||||||
|
parentId = null,
|
||||||
|
replyTo = null,
|
||||||
|
onCancelReply = null,
|
||||||
|
compact = false,
|
||||||
}) {
|
}) {
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
|
const [tab, setTab] = useState('write') // 'write' | 'preview'
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [errors, setErrors] = useState([])
|
const [errors, setErrors] = useState([])
|
||||||
const textareaRef = useRef(null)
|
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 insertAtCursor = useCallback((text) => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -32,11 +185,9 @@ export default function CommentForm({
|
|||||||
|
|
||||||
const start = el.selectionStart ?? content.length
|
const start = el.selectionStart ?? content.length
|
||||||
const end = el.selectionEnd ?? 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)
|
setContent(next)
|
||||||
|
|
||||||
// Restore cursor after the inserted text
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
el.selectionStart = start + text.length
|
el.selectionStart = start + text.length
|
||||||
el.selectionEnd = start + text.length
|
el.selectionEnd = start + text.length
|
||||||
@@ -48,6 +199,34 @@ export default function CommentForm({
|
|||||||
insertAtCursor(emoji)
|
insertAtCursor(emoji)
|
||||||
}, [insertAtCursor])
|
}, [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(
|
const handleSubmit = useCallback(
|
||||||
async (e) => {
|
async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -66,14 +245,18 @@ export default function CommentForm({
|
|||||||
try {
|
try {
|
||||||
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
|
parent_id: parentId || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
setContent('')
|
setContent('')
|
||||||
|
setTab('write')
|
||||||
onPosted?.(data.data)
|
onPosted?.(data.data)
|
||||||
|
onCancelReply?.()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 422) {
|
if (err.response?.status === 422) {
|
||||||
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
|
const fieldErrors = err.response.data?.errors ?? {}
|
||||||
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
|
const allErrors = Object.values(fieldErrors).flat()
|
||||||
|
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
|
||||||
} else {
|
} else {
|
||||||
setErrors(['Something went wrong. Please try again.'])
|
setErrors(['Something went wrong. Please try again.'])
|
||||||
}
|
}
|
||||||
@@ -81,62 +264,174 @@ export default function CommentForm({
|
|||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[artworkId, content, isLoggedIn, loginUrl, onPosted],
|
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/* ── Logged-out state ─────────────────────────────────────────────────── */
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
|
<div className="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4 backdrop-blur-sm">
|
||||||
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 shrink-0 text-white/25">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-white/40">
|
||||||
|
<a href={loginUrl} className="font-medium text-accent transition-colors hover:text-accent/80">
|
||||||
Sign in
|
Sign in
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
to leave a comment.
|
to join the conversation.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Editor ───────────────────────────────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-2">
|
<form id={parentId ? `reply-form-${parentId}` : 'comment-form'} ref={formRef} onSubmit={handleSubmit} className="space-y-3">
|
||||||
{/* Textarea */}
|
{/* Reply indicator */}
|
||||||
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
|
{replyTo && (
|
||||||
<textarea
|
<div className="flex items-center gap-2 rounded-lg bg-accent/[0.06] px-3 py-2">
|
||||||
ref={textareaRef}
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5 text-accent/60 shrink-0">
|
||||||
value={content}
|
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||||
onChange={(e) => setContent(e.target.value)}
|
</svg>
|
||||||
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
|
<span className="text-xs text-white/50">
|
||||||
rows={3}
|
Replying to <span className="font-semibold text-white/70">{replyTo}</span>
|
||||||
maxLength={10000}
|
</span>
|
||||||
disabled={submitting}
|
<button
|
||||||
aria-label="Comment text"
|
type="button"
|
||||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
|
onClick={onCancelReply}
|
||||||
/>
|
className="ml-auto text-[11px] font-medium text-white/30 transition-colors hover:text-white/60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toolbar at bottom-right of textarea */}
|
<div className={`rounded-2xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
|
||||||
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
|
||||||
|
{/* ── Top bar: tabs + emoji ─────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('write')}
|
||||||
|
className={[
|
||||||
|
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
tab === 'write'
|
||||||
|
? 'bg-white/[0.08] text-white'
|
||||||
|
: 'text-white/40 hover:text-white/60',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('preview')}
|
||||||
|
className={[
|
||||||
|
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
tab === 'preview'
|
||||||
|
? 'bg-white/[0.08] text-white'
|
||||||
|
: 'text-white/40 hover:text-white/60',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'text-xs tabular-nums transition-colors',
|
'text-[11px] tabular-nums font-medium transition-colors',
|
||||||
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
|
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-live="polite"
|
|
||||||
>
|
>
|
||||||
{content.length}/10 000
|
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown hint */}
|
{/* ── Formatting toolbar (write mode only) ──────────────────────── */}
|
||||||
<p className="text-xs text-white/25 px-1">
|
{tab === 'write' && (
|
||||||
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
|
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
|
||||||
|
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
|
||||||
|
<BoldIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
|
||||||
|
<ItalicIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
|
||||||
|
<CodeIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
|
||||||
|
<LinkIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
|
||||||
|
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
|
||||||
|
|
||||||
|
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
|
||||||
|
<ListIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
|
||||||
|
<QuoteIcon />
|
||||||
|
</ToolbarBtn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Write tab ─────────────────────────────────────────────────── */}
|
||||||
|
{tab === 'write' && (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={replyTo ? `Reply to ${replyTo}…` : 'Share your thoughts…'}
|
||||||
|
rows={compact ? 2 : 4}
|
||||||
|
maxLength={10000}
|
||||||
|
disabled={submitting}
|
||||||
|
aria-label="Comment text"
|
||||||
|
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Preview tab ───────────────────────────────────────────────── */}
|
||||||
|
{tab === 'preview' && (
|
||||||
|
<div className="min-h-[7rem] px-4 py-3">
|
||||||
|
{content.trim() ? (
|
||||||
|
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
|
||||||
|
<ReactMarkdown
|
||||||
|
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||||
|
unwrapDisallowed
|
||||||
|
components={{
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-white/25 italic">Nothing to preview</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Bottom hint ───────────────────────────────────────────────── */}
|
||||||
|
{tab === 'write' && (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<p className="text-[11px] text-white/15">
|
||||||
|
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<ul className="space-y-1" role="alert">
|
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
|
||||||
{errors.map((e, i) => (
|
{errors.map((e, i) => (
|
||||||
<li key={i} className="text-xs text-red-400 px-1">
|
<li key={i} className="text-xs font-medium text-red-400">
|
||||||
{e}
|
{e}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -148,9 +443,19 @@ export default function CommentForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !content.trim()}
|
disabled={submitting || !content.trim()}
|
||||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{submitting ? 'Posting…' : 'Post comment'}
|
{submitting ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Posting…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Post comment'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function CommentItem({ comment }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.onerror = null
|
e.currentTarget.onerror = null
|
||||||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
|
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/default/avatar_default.webp'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,42 +1,80 @@
|
|||||||
import React, { useCallback, useOptimistic, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
/* ── Reaction definitions ────────────────────────────────────────────────── */
|
||||||
|
const REACTIONS = [
|
||||||
|
{ slug: 'thumbs_up', emoji: '👍', label: 'Like' },
|
||||||
|
{ slug: 'heart', emoji: '❤️', label: 'Love' },
|
||||||
|
{ slug: 'fire', emoji: '🔥', label: 'Fire' },
|
||||||
|
{ slug: 'laugh', emoji: '😂', label: 'Haha' },
|
||||||
|
{ slug: 'clap', emoji: '👏', label: 'Clap' },
|
||||||
|
{ slug: 'wow', emoji: '😮', label: 'Wow' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ── Small heart outline icon for the trigger ─────────────────────────────── */
|
||||||
|
function HeartOutlineIcon({ className }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reaction bar for an artwork or comment.
|
* Facebook-style reaction bar.
|
||||||
|
*
|
||||||
|
* - Compact trigger button (heart icon or the user's reaction)
|
||||||
|
* - Floating picker that appears on hover/click with scale animation
|
||||||
|
* - Summary row showing unique reaction emoji + total count
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* entityType 'artwork' | 'comment'
|
* entityType 'artwork' | 'comment'
|
||||||
* entityId number
|
* entityId number
|
||||||
* initialTotals Record<slug, { emoji, label, count, mine }>
|
* initialTotals Record<slug, { emoji, label, count, mine }>
|
||||||
* isLoggedIn boolean — if false, clicking shows a prompt
|
* isLoggedIn boolean
|
||||||
*/
|
*/
|
||||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
||||||
const [totals, setTotals] = useState(initialTotals)
|
const [totals, setTotals] = useState(initialTotals)
|
||||||
const [loading, setLoading] = useState(null) // slug being toggled
|
const [loading, setLoading] = useState(null)
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const hoverTimeout = useRef(null)
|
||||||
|
|
||||||
const endpoint =
|
const endpoint =
|
||||||
entityType === 'artwork'
|
entityType === 'artwork'
|
||||||
? `/api/artworks/${entityId}/reactions`
|
? `/api/artworks/${entityId}/reactions`
|
||||||
: `/api/comments/${entityId}/reactions`
|
: `/api/comments/${entityId}/reactions`
|
||||||
|
|
||||||
|
// Close picker when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickerOpen) return
|
||||||
|
const handler = (e) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setPickerOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [pickerOpen])
|
||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
async (slug) => {
|
async (slug) => {
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (loading) return
|
||||||
if (loading) return // prevent double-click
|
|
||||||
setLoading(slug)
|
setLoading(slug)
|
||||||
|
setPickerOpen(false)
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setTotals((prev) => {
|
setTotals((prev) => {
|
||||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
const entry = prev[slug] ?? { count: 0, mine: false, emoji: REACTIONS.find(r => r.slug === slug)?.emoji, label: REACTIONS.find(r => r.slug === slug)?.label }
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[slug]: {
|
[slug]: {
|
||||||
...entry,
|
...entry,
|
||||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||||
mine: !entry.mine,
|
mine: !entry.mine,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -46,14 +84,13 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
|||||||
const { data } = await axios.post(endpoint, { reaction: slug })
|
const { data } = await axios.post(endpoint, { reaction: slug })
|
||||||
setTotals(data.totals)
|
setTotals(data.totals)
|
||||||
} catch {
|
} catch {
|
||||||
// Rollback
|
|
||||||
setTotals((prev) => {
|
setTotals((prev) => {
|
||||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[slug]: {
|
[slug]: {
|
||||||
...entry,
|
...entry,
|
||||||
count: entry.mine ? entry.count - 1 : entry.count + 1,
|
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||||
mine: !entry.mine,
|
mine: !entry.mine,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -65,46 +102,127 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
|||||||
[endpoint, isLoggedIn, loading],
|
[endpoint, isLoggedIn, loading],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Compute summary data
|
||||||
const entries = Object.entries(totals)
|
const entries = Object.entries(totals)
|
||||||
|
const activeReactions = entries.filter(([, info]) => info.count > 0)
|
||||||
|
const totalCount = activeReactions.reduce((sum, [, info]) => sum + info.count, 0)
|
||||||
|
const myReaction = entries.find(([, info]) => info.mine)?.[0] ?? null
|
||||||
|
const myReactionData = myReaction ? REACTIONS.find(r => r.slug === myReaction) : null
|
||||||
|
|
||||||
if (entries.length === 0) return null
|
// Hover handlers for desktop — open on hover with a small delay
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
clearTimeout(hoverTimeout.current)
|
||||||
|
hoverTimeout.current = setTimeout(() => setPickerOpen(true), 200)
|
||||||
|
}
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
clearTimeout(hoverTimeout.current)
|
||||||
|
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
ref={containerRef}
|
||||||
aria-label="Reactions"
|
className="flex items-center gap-2"
|
||||||
className="flex flex-wrap items-center gap-1.5"
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{entries.map(([slug, info]) => {
|
{/* ── Trigger button ──────────────────────────────────────────── */}
|
||||||
const { emoji, label, count, mine } = info
|
<div className="relative" onMouseEnter={onMouseEnter}>
|
||||||
const isProcessing = loading === slug
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (myReaction) {
|
||||||
|
// Quick-toggle: remove own reaction
|
||||||
|
toggle(myReaction)
|
||||||
|
} else {
|
||||||
|
// Quick-like with thumbs_up
|
||||||
|
toggle('thumbs_up')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||||
|
myReaction
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-white/40 hover:text-white/70',
|
||||||
|
].join(' ')}
|
||||||
|
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
|
||||||
|
>
|
||||||
|
{myReaction ? (
|
||||||
|
<span className="text-base leading-none">{myReactionData?.emoji}</span>
|
||||||
|
) : (
|
||||||
|
<HeartOutlineIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{myReaction ? myReactionData?.label : 'React'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── Floating picker ─────────────────────────────────────── */}
|
||||||
|
{pickerOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 mb-2 z-[200] animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||||
|
onMouseEnter={() => { clearTimeout(hoverTimeout.current) }}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-0.5 rounded-full bg-nova-800/95 border border-white/[0.1] px-2 py-1.5 shadow-xl shadow-black/40 backdrop-blur-xl">
|
||||||
|
{REACTIONS.map((r, i) => {
|
||||||
|
const isActive = totals[r.slug]?.mine
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={slug}
|
key={r.slug}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isProcessing}
|
onClick={() => toggle(r.slug)}
|
||||||
onClick={() => toggle(slug)}
|
disabled={loading === r.slug}
|
||||||
aria-label={`${label} — ${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
|
aria-label={`${r.label}${isActive ? ' (selected)' : ''}`}
|
||||||
aria-pressed={mine}
|
|
||||||
className={[
|
className={[
|
||||||
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
|
'group/reaction relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200',
|
||||||
'border transition-all duration-150',
|
'hover:bg-white/[0.08] hover:scale-125 hover:-translate-y-1',
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||||
'disabled:opacity-50 disabled:pointer-events-none',
|
'disabled:opacity-50',
|
||||||
mine
|
isActive ? 'bg-white/[0.1] scale-110' : '',
|
||||||
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
|
].join(' ')}
|
||||||
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
|
style={{ animationDelay: `${i * 30}ms` }}
|
||||||
]
|
title={r.label}
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{emoji}</span>
|
<span className="text-xl leading-none transition-transform duration-150 group-hover/reaction:scale-110">
|
||||||
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
|
{r.emoji}
|
||||||
<span className="sr-only">{label}</span>
|
</span>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<span className="pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 rounded bg-black/80 px-1.5 py-0.5 text-[10px] font-medium text-white/90 opacity-0 transition-opacity group-hover/reaction:opacity-100 whitespace-nowrap">
|
||||||
|
{r.label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Summary: stacked emoji + count ───────────────────────── */}
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(v => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
|
||||||
|
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Stacked emoji circles (Facebook-style, max 3) */}
|
||||||
|
<span className="inline-flex items-center -space-x-1">
|
||||||
|
{activeReactions.slice(0, 3).map(([slug, info], i) => (
|
||||||
|
<span
|
||||||
|
key={slug}
|
||||||
|
className="relative flex items-center justify-center w-5 h-5 rounded-full bg-nova-700 border border-nova-800 text-xs leading-none"
|
||||||
|
style={{ zIndex: 3 - i }}
|
||||||
|
title={info.label}
|
||||||
|
>
|
||||||
|
{info.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium tabular-nums text-white/50 group-hover/summary:text-white/70 transition-colors">
|
||||||
|
{totalCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
|||||||
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
|
const authorUrl = username ? `/@${username.toLowerCase()}` : null;
|
||||||
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
|
// Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default()
|
||||||
const cdnBase = 'https://files.skinbase.org';
|
const cdnBase = 'https://files.skinbase.org';
|
||||||
const avatarSrc = art.avatar_url || `${cdnBase}/avatars/default.webp`;
|
const avatarSrc = art.avatar_url || `${cdnBase}/default/avatar_default.webp`;
|
||||||
|
|
||||||
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||||
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
$imageObject = [
|
$imageObject = [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'ImageObject',
|
'@type' => 'ImageObject',
|
||||||
'name' => (string) $artwork->title,
|
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'description' => (string) ($artwork->description ?? ''),
|
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'url' => $meta['canonical'],
|
'url' => $meta['canonical'],
|
||||||
'contentUrl' => $meta['og_image'] ?? null,
|
'contentUrl' => $meta['og_image'] ?? null,
|
||||||
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
|
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
$creativeWork = [
|
$creativeWork = [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'CreativeWork',
|
'@type' => 'CreativeWork',
|
||||||
'name' => (string) $artwork->title,
|
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'description' => (string) ($artwork->description ?? ''),
|
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'url' => $meta['canonical'],
|
'url' => $meta['canonical'],
|
||||||
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
|
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
|
||||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
|
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
|
||||||
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
||||||
class="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
class="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||||
onerror="this.src='https://files.skinbase.org/avatars/default.webp'" />
|
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
|
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
|
||||||
@if($creator->username ?? null)
|
@if($creator->username ?? null)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch)
|
// GET /api/art/{id}/similar → up to 12 similar artworks (Meilisearch)
|
||||||
// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min)
|
// POST /api/art/{id}/view → record a view (session-deduped, 5 per 10 min)
|
||||||
// POST /api/art/{id}/download → record a download, returns file URL (10/min)
|
// POST /api/art/{id}/download → record a download, returns file URL (10/min)
|
||||||
Route::middleware(['web', 'throttle:60,1'])
|
Route::middleware(['web', 'throttle:300,1'])
|
||||||
->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class)
|
->get('art/{id}/similar', \App\Http\Controllers\Api\SimilarArtworksController::class)
|
||||||
->whereNumber('id')
|
->whereNumber('id')
|
||||||
->name('api.art.similar');
|
->name('api.art.similar');
|
||||||
|
|||||||
11
scripts/check_col_charset.php
Normal file
11
scripts/check_col_charset.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||||
|
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||||
|
|
||||||
|
$rows = \Illuminate\Support\Facades\DB::select("SHOW FULL COLUMNS FROM artwork_comments");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (in_array($r->Field, ['content', 'raw_content', 'rendered_content'])) {
|
||||||
|
echo $r->Field . ' => type=' . $r->Type . ' collation=' . ($r->Collation ?? 'NULL') . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
scripts/check_reply_tree.php
Normal file
33
scripts/check_reply_tree.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||||
|
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||||
|
|
||||||
|
$rows = App\Models\ArtworkComment::where('artwork_id', 10)
|
||||||
|
->whereNotNull('parent_id')
|
||||||
|
->select('id', 'parent_id', 'content')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(15)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo "id={$r->id} parent_id={$r->parent_id} content=" . mb_substr($r->content, 0, 40) . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n--- Tree test (recursive eager-load) ---\n";
|
||||||
|
$top = App\Models\ArtworkComment::with(['approvedReplies'])
|
||||||
|
->where('id', 175742)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
function printTree($comments, $indent = 0) {
|
||||||
|
foreach ($comments as $c) {
|
||||||
|
$prefix = str_repeat(' ', $indent);
|
||||||
|
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||||
|
echo "{$prefix}[{$c->id}] " . mb_substr($c->content, 0, 40) . " ({$replies->count()} replies)\n";
|
||||||
|
if ($replies->count()) {
|
||||||
|
printTree($replies, $indent + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printTree($top);
|
||||||
38
scripts/rerender_comments.php
Normal file
38
scripts/rerender_comments.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||||
|
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||||
|
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Services\ContentSanitizer;
|
||||||
|
|
||||||
|
// Test on a single comment first
|
||||||
|
$c = ArtworkComment::find(175742);
|
||||||
|
if (!$c) { echo "Comment not found\n"; exit(1); }
|
||||||
|
|
||||||
|
echo "raw_content: " . $c->raw_content . PHP_EOL;
|
||||||
|
echo "old rendered: " . $c->rendered_content . PHP_EOL;
|
||||||
|
|
||||||
|
$new = ContentSanitizer::render($c->raw_content);
|
||||||
|
echo "new rendered: " . $new . PHP_EOL;
|
||||||
|
|
||||||
|
// Now re-render all
|
||||||
|
$count = 0;
|
||||||
|
$errors = 0;
|
||||||
|
ArtworkComment::whereNotNull('raw_content')
|
||||||
|
->where('raw_content', '!=', '')
|
||||||
|
->chunk(100, function ($comments) use (&$count, &$errors) {
|
||||||
|
foreach ($comments as $c) {
|
||||||
|
try {
|
||||||
|
$c->rendered_content = ContentSanitizer::render($c->raw_content);
|
||||||
|
$c->timestamps = false;
|
||||||
|
$c->saveQuietly();
|
||||||
|
$count++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
echo "Error on comment #{$c->id}: " . $e->getMessage() . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
echo "Re-rendered {$count} comments ({$errors} errors)." . PHP_EOL;
|
||||||
Reference in New Issue
Block a user