feat: artwork page carousels, recommendations, avatars & fixes

- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
2026-02-28 14:05:39 +01:00
parent 80100c7651
commit eee7df1f8c
46 changed files with 2536 additions and 498 deletions

View File

@@ -20,6 +20,32 @@ function timeAgo(dateStr) {
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 }) {
if (user?.avatar_url) {
return (
@@ -28,12 +54,12 @@ function Avatar({ user, size = 36 }) {
alt={user.name || user.username || ''}
width={size}
height={size}
className="rounded-full object-cover shrink-0"
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
style={{ width: size, height: size }}
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp'
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
@@ -41,7 +67,7 @@ function Avatar({ user, size = 36 }) {
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
return (
<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 }}
>
{initials}
@@ -49,21 +75,172 @@ function Avatar({ user, size = 36 }) {
)
}
// ── Single comment ────────────────────────────────────────────────────────────
// ── Reply item (nested under a parent) ────────────────────────────────────────
function CommentItem({ comment, isLoggedIn }) {
const user = comment.user
const html = comment.rendered_content ?? null
const plain = comment.content ?? ''
function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, depth = 1 }) {
const user = reply.user
const html = reply.rendered_content ?? null
const plain = reply.content ?? reply.raw_content ?? ''
const profileLabel = user?.display || user?.username || user?.name || 'Member'
const replies = reply.replies || []
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
const flood = isFlood(plain)
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
useEffect(() => {
if (reply.reactions || !reply.id) return
axios
.get(`/api/comments/${reply.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [reply.id, reply.reactions])
const handleReplyPosted = useCallback((newReply) => {
// Reply posts under THIS reply's id as parent
onReplyPosted?.(reply.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [reply.id, onReplyPosted])
// Show first 2 nested replies, expand to show all
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
// Shrink avatar at deeper levels
const avatarSize = depth >= 3 ? 22 : 28
return (
<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 html = comment.rendered_content ?? null
const plain = comment.content ?? comment.raw_content ?? ''
const profileLabel = user?.display || user?.username || user?.name || 'Member'
const replies = comment.replies || []
const flood = isFlood(plain)
const [expanded, setExpanded] = useState(!flood)
const [showReplyForm, setShowReplyForm] = useState(false)
const [showAllReplies, setShowAllReplies] = useState(false)
// Build initial reaction totals (empty if not provided by server)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
// Load reactions lazily if not provided
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
@@ -72,92 +249,159 @@ function CommentItem({ comment, isLoggedIn }) {
.catch(() => {})
}, [comment.id, comment.reactions])
return (
<li className="flex gap-3" id={`comment-${comment.id}`}>
{/* Avatar */}
{user?.profile_url ? (
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={36} />
</a>
) : (
<span className="shrink-0 mt-0.5">
<Avatar user={user} size={36} />
</span>
)}
const handleReplyPosted = useCallback((newReply) => {
onReplyPosted?.(comment.id, newReply)
setShowReplyForm(false)
setShowAllReplies(true)
}, [comment.id, onReplyPosted])
{/* Content */}
<div className="min-w-0 flex-1 space-y-1.5">
{/* Header */}
<div className="flex items-baseline gap-2 flex-wrap">
// Show first 2 replies by default, expand to show all
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
const hiddenReplyCount = replies.length - 2
return (
<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 */}
{user?.profile_url ? (
<a href={user.profile_url} className="text-sm font-medium text-white hover:underline">
{user.display || user.username || user.name || 'Member'}
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
<Avatar user={user} size={38} />
</a>
) : (
<span className="text-sm font-medium text-white">
{user?.display || user?.username || user?.name || 'Member'}
</span>
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
)}
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-xs text-neutral-500"
>
{comment.time_ago || timeAgo(comment.created_at)}
</time>
</div>
{/* Body — use rendered_content (safe HTML) when available, else plain text */}
{/* Flood-collapse wrapper: clips height when content is a repeated-emoji flood */}
<div
className={!expanded ? 'overflow-hidden relative' : undefined}
style={!expanded ? { maxHeight: '5em' } : undefined}
>
{html ? (
{/* Content */}
<div className="min-w-0 flex-1 space-y-2">
{/* Header */}
<div className="flex items-center gap-2 flex-wrap">
{user?.profile_url ? (
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
{profileLabel}
</a>
) : (
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
)}
<span className="text-white/15" aria-hidden="true">·</span>
<time
dateTime={comment.created_at}
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
>
{comment.time_ago || timeAgo(comment.created_at)}
</time>
</div>
{/* Body */}
<div
className="text-sm text-neutral-300 leading-relaxed prose prose-invert prose-sm max-w-none
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 }}
/>
) : (
<p className="text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
{plain}
</p>
)}
className={!expanded ? 'overflow-hidden relative' : undefined}
style={!expanded ? { maxHeight: '5em' } : undefined}
>
{html ? (
<div
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"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
)}
{/* Gradient fade at the bottom while collapsed */}
{flood && !expanded && (
<div
className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-neutral-900 to-transparent pointer-events-none"
aria-hidden="true"
/>
)}
{flood && !expanded && (
<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" />
)}
</div>
{flood && (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
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}
>
{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>
)}
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
</div>
</div>
</div>
{/* Flood expand / collapse toggle */}
{flood && (
<button
type="button"
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"
aria-expanded={expanded}
>
{expanded ? '▲\u2009Collapse' : '▼\u2009Show full comment'}
</button>
)}
{/* Reactions */}
{Object.keys(reactionTotals).length > 0 && (
<ReactionBar
entityType="comment"
entityId={comment.id}
initialTotals={reactionTotals}
isLoggedIn={isLoggedIn}
/>
)}
</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>
)}
</li>
)
}
@@ -166,14 +410,24 @@ function CommentItem({ comment, isLoggedIn }) {
function Skeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="w-9 h-9 rounded-full bg-white/[0.07] shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-3 bg-white/[0.07] rounded w-28" />
<div className="h-3 bg-white/[0.05] rounded w-full" />
<div className="h-3 bg-white/[0.04] rounded w-2/3" />
<div
key={i}
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
style={{ animationDelay: `${i * 120}ms` }}
>
<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>
))}
@@ -183,19 +437,6 @@ function Skeleton() {
// ── Main export ───────────────────────────────────────────────────────────────
/**
* ArtworkComments
*
* Can operate in two modes:
* 1. Static: pass `comments` array from Inertia page props (legacy / SSR)
* 2. Dynamic: pass `artworkId` to load + post comments via the API
*
* Props:
* artworkId number Used for API calls
* comments array SSR initial comments (optional)
* isLoggedIn boolean
* loginUrl string
*/
export default function ArtworkComments({
artworkId,
comments: initialComments = [],
@@ -209,7 +450,6 @@ export default function ArtworkComments({
const [total, setTotal] = useState(initialComments.length)
const initialized = useRef(false)
// Load comments from API
const loadComments = useCallback(
async (p = 1) => {
if (!artworkId) return
@@ -225,7 +465,7 @@ export default function ArtworkComments({
setLastPage(data.meta?.last_page ?? 1)
setTotal(data.meta?.total ?? 0)
} catch {
// keep existing data on error
// keep existing
} finally {
setLoading(false)
}
@@ -233,7 +473,6 @@ export default function ArtworkComments({
[artworkId],
)
// On mount, load if artworkId provided and no SSR comments given
useEffect(() => {
if (initialized.current) return
initialized.current = true
@@ -245,21 +484,95 @@ export default function ArtworkComments({
}
}, [artworkId, initialComments.length, loadComments])
// New top-level comment posted
const handlePosted = useCallback((newComment) => {
setComments((prev) => [newComment, ...prev])
// Ensure it has a replies array
const comment = { ...newComment, replies: newComment.replies || [] }
setComments((prev) => [comment, ...prev])
setTotal((t) => t + 1)
}, [])
// Reply posted under a parent comment (works at any nesting depth)
const handleReplyPosted = useCallback((parentId, newReply) => {
// Recursively find the parent node and append the reply
const insertReply = (nodes) =>
nodes.map((c) => {
if (c.id === parentId) {
return { ...c, replies: [...(c.replies || []), { ...newReply, replies: [] }] }
}
if (c.replies?.length) {
return { ...c, replies: insertReply(c.replies) }
}
return c
})
setComments((prev) => insertReply(prev))
setTotal((t) => t + 1)
}, [])
return (
<section aria-label="Comments" className="space-y-6">
<h2 className="text-base font-semibold text-white">
Comments{' '}
{/* Section header */}
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">
Comments
</h2>
{total > 0 && (
<span className="text-neutral-500 font-normal">({total})</span>
<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>
)}
</h2>
</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 && (
<CommentForm
artworkId={artworkId}
@@ -268,39 +581,6 @@ export default function ArtworkComments({
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>
)
}