messages implemented
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import React from 'react'
|
||||
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 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)
|
||||
@@ -25,6 +31,10 @@ function Avatar({ user, size = 36 }) {
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -39,59 +49,259 @@ function Avatar({ user, size = 36 }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkComments({ comments = [] }) {
|
||||
if (!comments || comments.length === 0) return null
|
||||
// ── 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 (
|
||||
<section aria-label="Comments">
|
||||
<h2 className="text-base font-semibold text-white mb-4">
|
||||
<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{' '}
|
||||
<span className="text-neutral-500 font-normal">({comments.length})</span>
|
||||
{total > 0 && (
|
||||
<span className="text-neutral-500 font-normal">({total})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
{/* Comment form */}
|
||||
{artworkId && (
|
||||
<CommentForm
|
||||
artworkId={artworkId}
|
||||
onPosted={handlePosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
loginUrl={loginUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
{comment.user?.profile_url ? (
|
||||
<a
|
||||
href={comment.user.profile_url}
|
||||
className="text-sm font-medium text-white hover:underline"
|
||||
>
|
||||
{comment.user.name || comment.user.username || 'Member'}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{comment.user?.name || comment.user?.username || 'Member'}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
{/* 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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user