messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -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>
)
}