import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import CommentForm from '../comments/CommentForm'
import ReactionBar from '../comments/ReactionBar'
import LevelBadge from '../xp/LevelBadge'
import { isFlood } from '../../utils/emojiFlood'
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
})
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatAbsoluteDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_FORMATTER.format(date)
}
function formatAbsoluteDateTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
}
function formatCommentTime(primaryLabel, createdAt) {
return primaryLabel || formatAbsoluteDate(createdAt)
}
/* ── 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,
isLoggedIn = false,
loginUrl = '/login',
}) {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [page, setPage] = useState(1)
const [lastPage, setLastPage] = useState(1)
const [total, setTotal] = useState(0)
const sectionRef = useRef(null)
const requestRef = useRef(null)
const generationRef = useRef(0)
const loadComments = useCallback(
async (p = 1) => {
if (!artworkId || requestRef.current) return
const generation = generationRef.current
const controller = new AbortController()
requestRef.current = controller
setLoading(true)
setError(false)
try {
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`, { signal: controller.signal })
if (generation !== generationRef.current) return
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 {
if (generation === generationRef.current && !controller.signal.aborted) setError(true)
} finally {
if (requestRef.current === controller) {
requestRef.current = null
if (generation === generationRef.current) setLoading(false)
}
}
},
[artworkId],
)
useEffect(() => {
generationRef.current += 1
requestRef.current?.abort()
requestRef.current = null
setComments([])
setLoading(false)
setError(false)
setPage(1)
setLastPage(1)
setTotal(0)
const section = sectionRef.current
if (!section || !artworkId) return undefined
let fallbackTimer = null
let observer = null
let established = false
const trigger = () => {
observer?.disconnect()
if (!requestRef.current) loadComments(1)
}
if (typeof window !== 'undefined' && typeof window.IntersectionObserver === 'function') {
try {
observer = new window.IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) trigger()
}, { rootMargin: '350px 0px' })
observer.observe(section)
established = true
} catch {
observer?.disconnect()
}
}
if (!established && typeof window !== 'undefined') {
fallbackTimer = window.setTimeout(trigger, 1200)
}
return () => {
observer?.disconnect()
if (fallbackTimer !== null && typeof window !== 'undefined') window.clearTimeout(fallbackTimer)
requestRef.current?.abort()
requestRef.current = null
}
}, [artworkId, 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 ? (
) : error && comments.length === 0 ? (
Comments could not be loaded.
) : comments.length === 0 ? (
No comments yet
Be the first to share your thoughts.
) : (
<>
{comments.map((comment) => (
))}
{page < lastPage && (
)}
>
)}
{/* Comment form — after all comments */}
{artworkId && (
)}
)
}