import React, { useCallback, useEffect, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import axios from 'axios' import CommentForm from '../../components/comments/CommentForm' import ReactionBar from '../../components/comments/ReactionBar' import LevelBadge from '../../components/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', }) 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) } function ReplyIcon() { return ( ) } function TrashIcon() { return ( ) } function ChatBubbleIcon() { return ( ) } function ChevronDownIcon({ className }) { return ( ) } function Avatar({ user, size = 36 }) { if (user?.avatar_url) { return ( {user.display { event.currentTarget.onerror = null event.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp' }} /> ) } const initials = (user?.display || user?.username || '?').slice(0, 1).toUpperCase() return ( {initials} ) } function DeleteButton({ onDelete, pending = false }) { return ( ) } function ReplyItem({ reply, articleId, isLoggedIn, onReplyPosted, onDelete, deletePending = false, depth = 1 }) { const user = reply.user const html = reply.rendered_content ?? null const plain = reply.raw_content ?? '' const profileLabel = user?.display || user?.username || 'Member' const replies = reply.replies || [] const reactionEndpoint = `/api/news/comments/${reply.id}/reactions` 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(reactionEndpoint) .then(({ data }) => setReactionTotals(data.totals ?? {})) .catch(() => {}) }, [reply.id, reply.reactions, reactionEndpoint]) const handleReplyPosted = useCallback((newReply) => { onReplyPosted?.(reply.id, newReply) setShowReplyForm(false) setShowAllReplies(true) }, [onReplyPosted, reply.id]) const visibleReplies = showAllReplies ? replies : replies.slice(0, 2) const hiddenReplyCount = replies.length - 2 const avatarSize = depth >= 3 ? 22 : 28 return (
  • {user?.profile_url ? ( ) : ( )}
    {user?.profile_url ? ( {profileLabel} ) : ( {profileLabel} )}
    {html ? (
    ) : (

    {plain}

    )}
    {isLoggedIn && ( )} {reply.can_delete ? onDelete(reply.id)} pending={deletePending} /> : null}
    {showReplyForm ? (
    setShowReplyForm(false)} isLoggedIn={isLoggedIn} compact />
    ) : null} {replies.length > 0 ? (
      = 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}> {visibleReplies.map((child) => ( ))}
    {!showAllReplies && hiddenReplyCount > 0 ? ( ) : null}
    ) : null}
  • ) } function CommentItem({ comment, isLoggedIn, articleId, onReplyPosted, onDelete, deletePending = false }) { const user = comment.user const html = comment.rendered_content ?? null const plain = comment.raw_content ?? '' const profileLabel = user?.display || user?.username || 'Member' const replies = comment.replies || [] const flood = isFlood(plain) const reactionEndpoint = `/api/news/comments/${comment.id}/reactions` 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(reactionEndpoint) .then(({ data }) => setReactionTotals(data.totals ?? {})) .catch(() => {}) }, [comment.id, comment.reactions, reactionEndpoint]) const handleReplyPosted = useCallback((newReply) => { onReplyPosted?.(comment.id, newReply) setShowReplyForm(false) setShowAllReplies(true) }, [comment.id, onReplyPosted]) const visibleReplies = showAllReplies ? replies : replies.slice(0, 2) const hiddenReplyCount = replies.length - 2 return (
  • {user?.profile_url ? ( ) : ( )}
    {user?.profile_url ? ( {profileLabel} ) : ( {profileLabel} )}
    {html ? (
    ) : (

    {plain}

    )} {flood && !expanded ? ( {flood ? ( ) : null}
    {isLoggedIn ? ( ) : null} {comment.can_delete ? onDelete(comment.id)} pending={deletePending} /> : null}
    {(replies.length > 0 || showReplyForm) ? (
    {replies.length > 0 ? ( <>
      {visibleReplies.map((reply) => ( ))}
    {!showAllReplies && hiddenReplyCount > 0 ? ( ) : null} ) : null} {showReplyForm ? (
    0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}> setShowReplyForm(false)} isLoggedIn={isLoggedIn} compact />
    ) : null}
    ) : null}
  • ) } function Skeleton() { return (
    {Array.from({ length: 3 }).map((_, index) => (
    ))}
    ) } function NewsComments({ articleId, isLoggedIn = false, loginUrl = '/login' }) { const [comments, setComments] = useState([]) const [loading, setLoading] = useState(false) const [page, setPage] = useState(1) const [lastPage, setLastPage] = useState(1) const [total, setTotal] = useState(0) const [deletingId, setDeletingId] = useState(null) const initialized = useRef(false) const loadComments = useCallback(async (nextPage = 1) => { if (!articleId) return setLoading(true) try { const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`) if (nextPage === 1) { setComments(data.data ?? []) } else { setComments((current) => [...current, ...(data.data ?? [])]) } setPage(data.meta?.current_page ?? nextPage) setLastPage(data.meta?.last_page ?? 1) setTotal(data.meta?.total ?? 0) } catch { // Keep the server-rendered fallback in place if the API is unavailable. } finally { setLoading(false) } }, [articleId]) useEffect(() => { if (initialized.current) return initialized.current = true loadComments(1) }, [loadComments]) const handlePosted = useCallback((newComment) => { const comment = { ...newComment, replies: newComment.replies || [] } setComments((current) => [comment, ...current]) setTotal((current) => current + 1) }, []) const handleReplyPosted = useCallback((parentId, newReply) => { const insertReply = (nodes) => nodes.map((comment) => { if (comment.id === parentId) { return { ...comment, replies: [...(comment.replies || []), { ...newReply, replies: [] }] } } if (comment.replies?.length) { return { ...comment, replies: insertReply(comment.replies) } } return comment }) setComments((current) => insertReply(current)) setTotal((current) => current + 1) }, []) const handleDelete = useCallback(async (commentId) => { if (!commentId || deletingId) return setDeletingId(commentId) try { await axios.delete(`/api/news/articles/${articleId}/comments/${commentId}`) await loadComments(1) } catch { // Preserve the current state if deletion fails. } finally { setDeletingId(null) } }, [articleId, deletingId, loadComments]) return (

    Comments

    {total > 0 ? ( {total} ) : null}
    {loading && comments.length === 0 ? ( ) : comments.length === 0 ? (

    No comments yet

    Be the first to share your thoughts.

    ) : ( <>
      {comments.map((comment) => ( ))}
    {page < lastPage ? (
    ) : null} )} {articleId ? ( ) : null}
    ) } if (typeof document !== 'undefined') { const mountEl = document.getElementById('news-comments-root') const propsEl = document.getElementById('news-comments-props') if (mountEl && propsEl) { let props = {} try { props = JSON.parse(propsEl.textContent || '{}') } catch { props = {} } createRoot(mountEl).render() } } export default NewsComments