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' })
}
/* ── Icons ─────────────────────────────────────────────────────────────────── */
function ReplyIcon() {
return (
)
}
function ChatBubbleIcon() {
return (
)
}
function ChevronDownIcon({ className }) {
return (
)
}
/* ── Avatar ─────────────────────────────────────────────────────────────────── */
function Avatar({ user, size = 36 }) {
if (user?.avatar_url) {
return (
{
e.currentTarget.onerror = null
e.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
)
}
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
return (
{initials}
)
}
// ── Reply item (nested under a parent) ────────────────────────────────────────
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 (
)
}
// ── 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)
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
useEffect(() => {
if (comment.reactions || !comment.id) return
axios
.get(`/api/comments/${comment.id}/reactions`)
.then(({ data }) => setReactionTotals(data.totals ?? {}))
.catch(() => {})
}, [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 (
)
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
function Skeleton() {
return (
{Array.from({ length: 3 }).map((_, i) => (
))}
)
}
// ── Main export ───────────────────────────────────────────────────────────────
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)
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
} finally {
setLoading(false)
}
},
[artworkId],
)
useEffect(() => {
if (initialized.current) return
initialized.current = true
if (artworkId && initialComments.length === 0) {
loadComments(1)
} else {
setTotal(initialComments.length)
}
}, [artworkId, initialComments.length, loadComments])
// New top-level comment posted
const handlePosted = useCallback((newComment) => {
// 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 header */}
Comments
{total > 0 && (
{total}
)}
{/* Comment list */}
{loading && comments.length === 0 ? (
) : comments.length === 0 ? (
No comments yet
Be the first to share your thoughts.
) : (
<>
{comments.map((comment) => (
))}
{page < lastPage && (
)}
>
)}
{/* Comment form — after all comments */}
{artworkId && (
)}
)
}