308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
import axios from 'axios'
|
|
import CommentForm from '../comments/CommentForm'
|
|
import ReactionBar from '../comments/ReactionBar'
|
|
import { isFlood } from '../../utils/emojiFlood'
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function timeAgo(dateStr) {
|
|
if (!dateStr) return ''
|
|
const date = new Date(dateStr)
|
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
|
if (seconds < 60) return 'just now'
|
|
const minutes = Math.floor(seconds / 60)
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
if (days < 365) return `${days}d ago`
|
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
function Avatar({ user, size = 36 }) {
|
|
if (user?.avatar_url) {
|
|
return (
|
|
<img
|
|
src={user.avatar_url}
|
|
alt={user.name || user.username || ''}
|
|
width={size}
|
|
height={size}
|
|
className="rounded-full object-cover shrink-0"
|
|
style={{ width: size, height: size }}
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
e.currentTarget.onerror = null
|
|
e.currentTarget.src = 'https://files.skinbase.org/avatars/default.webp'
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
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"
|
|
style={{ width: size, height: size }}
|
|
>
|
|
{initials}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ── Single comment ────────────────────────────────────────────────────────────
|
|
|
|
function CommentItem({ comment, isLoggedIn }) {
|
|
const user = comment.user
|
|
const html = comment.rendered_content ?? null
|
|
const plain = comment.content ?? ''
|
|
|
|
// Emoji-flood collapse: long runs of repeated emoji get a show-more toggle.
|
|
const flood = isFlood(plain)
|
|
const [expanded, setExpanded] = useState(!flood)
|
|
|
|
// 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
|
|
.get(`/api/comments/${comment.id}/reactions`)
|
|
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
|
.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>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
{/* Header */}
|
|
<div className="flex items-baseline gap-2 flex-wrap">
|
|
{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>
|
|
) : (
|
|
<span className="text-sm font-medium text-white">
|
|
{user?.display || user?.username || user?.name || 'Member'}
|
|
</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 ? (
|
|
<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>
|
|
)}
|
|
|
|
{/* 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"
|
|
/>
|
|
)}
|
|
</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>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
// ── Skeleton ──────────────────────────────────────────────────────────────────
|
|
|
|
function Skeleton() {
|
|
return (
|
|
<div className="space-y-4 animate-pulse">
|
|
{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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 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 = [],
|
|
isLoggedIn = false,
|
|
loginUrl = '/login',
|
|
}) {
|
|
const [comments, setComments] = useState(initialComments)
|
|
const [loading, setLoading] = useState(false)
|
|
const [page, setPage] = useState(1)
|
|
const [lastPage, setLastPage] = useState(1)
|
|
const [total, setTotal] = useState(initialComments.length)
|
|
const initialized = useRef(false)
|
|
|
|
// Load comments from API
|
|
const loadComments = useCallback(
|
|
async (p = 1) => {
|
|
if (!artworkId) return
|
|
setLoading(true)
|
|
try {
|
|
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`)
|
|
if (p === 1) {
|
|
setComments(data.data ?? [])
|
|
} else {
|
|
setComments((prev) => [...prev, ...(data.data ?? [])])
|
|
}
|
|
setPage(data.meta?.current_page ?? p)
|
|
setLastPage(data.meta?.last_page ?? 1)
|
|
setTotal(data.meta?.total ?? 0)
|
|
} catch {
|
|
// keep existing data on error
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[artworkId],
|
|
)
|
|
|
|
// On mount, load if artworkId provided and no SSR comments given
|
|
useEffect(() => {
|
|
if (initialized.current) return
|
|
initialized.current = true
|
|
|
|
if (artworkId && initialComments.length === 0) {
|
|
loadComments(1)
|
|
} else {
|
|
setTotal(initialComments.length)
|
|
}
|
|
}, [artworkId, initialComments.length, loadComments])
|
|
|
|
const handlePosted = useCallback((newComment) => {
|
|
setComments((prev) => [newComment, ...prev])
|
|
setTotal((t) => t + 1)
|
|
}, [])
|
|
|
|
return (
|
|
<section aria-label="Comments" className="space-y-6">
|
|
<h2 className="text-base font-semibold text-white">
|
|
Comments{' '}
|
|
{total > 0 && (
|
|
<span className="text-neutral-500 font-normal">({total})</span>
|
|
)}
|
|
</h2>
|
|
|
|
{/* Comment form */}
|
|
{artworkId && (
|
|
<CommentForm
|
|
artworkId={artworkId}
|
|
onPosted={handlePosted}
|
|
isLoggedIn={isLoggedIn}
|
|
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>
|
|
)
|
|
}
|
|
|