Add news article comments and reactions

This commit is contained in:
2026-05-01 11:43:49 +02:00
parent 874f8feb9c
commit 28e7e46e13
22 changed files with 20083 additions and 26 deletions

View File

@@ -85,6 +85,12 @@ export default function CommentForm({
replyTo = null,
onCancelReply = null,
compact = false,
submitUrl = null,
contentField = 'content',
maxLength = 10000,
placeholder = 'Share your thoughts…',
submitLabel = 'Post comment',
submittingLabel = 'Posting…',
}) {
const [content, setContent] = useState('')
const [tab, setTab] = useState('write') // 'write' | 'preview'
@@ -92,6 +98,8 @@ export default function CommentForm({
const [errors, setErrors] = useState([])
const textareaRef = useRef(null)
const formRef = useRef(null)
const resolvedSubmitUrl = submitUrl || (artworkId ? `/api/artworks/${artworkId}/comments` : null)
const warningThreshold = Math.max(1, Math.floor(maxLength * 0.9))
// Auto-focus when entering reply mode
useEffect(() => {
@@ -237,14 +245,14 @@ export default function CommentForm({
}
const trimmed = content.trim()
if (!trimmed) return
if (!trimmed || !resolvedSubmitUrl) return
setSubmitting(true)
setErrors([])
try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
content: trimmed,
const { data } = await axios.post(resolvedSubmitUrl, {
[contentField]: trimmed,
parent_id: parentId || null,
})
@@ -255,7 +263,12 @@ export default function CommentForm({
} catch (err) {
if (err.response?.status === 422) {
const fieldErrors = err.response.data?.errors ?? {}
const allErrors = Object.values(fieldErrors).flat()
const allErrors = [
...(Array.isArray(fieldErrors[contentField]) ? fieldErrors[contentField] : []),
...Object.entries(fieldErrors)
.filter(([field]) => field !== contentField)
.flatMap(([, messages]) => Array.isArray(messages) ? messages : []),
]
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
} else {
setErrors(['Something went wrong. Please try again.'])
@@ -264,7 +277,7 @@ export default function CommentForm({
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
[content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl],
)
/* ── Logged-out state ─────────────────────────────────────────────────── */
@@ -342,10 +355,10 @@ export default function CommentForm({
<span
className={[
'text-[11px] tabular-nums font-medium transition-colors',
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
content.length > warningThreshold ? 'text-amber-400/80' : 'text-white/20',
].join(' ')}
>
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
{content.length > 0 && `${content.length.toLocaleString()}/${maxLength.toLocaleString()}`}
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div>
@@ -385,9 +398,9 @@ export default function CommentForm({
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={replyTo ? `Reply to ${replyTo}` : 'Share your thoughts…'}
placeholder={replyTo ? `Reply to ${replyTo}` : placeholder}
rows={compact ? 2 : 4}
maxLength={10000}
maxLength={maxLength}
disabled={submitting}
aria-label="Comment text"
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
@@ -451,10 +464,10 @@ export default function CommentForm({
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Posting
{submittingLabel}
</span>
) : (
'Post comment'
submitLabel
)}
</button>
</div>

View File

@@ -32,18 +32,24 @@ function HeartOutlineIcon({ className }) {
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean
* endpoint string | null
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false, endpoint = null }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null)
const [pickerOpen, setPickerOpen] = useState(false)
const containerRef = useRef(null)
const hoverTimeout = useRef(null)
const endpoint =
useEffect(() => {
setTotals(initialTotals ?? {})
}, [entityId, initialTotals])
const resolvedEndpoint = endpoint || (
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
)
// Close picker when clicking outside
useEffect(() => {
@@ -81,7 +87,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
})
try {
const { data } = await axios.post(endpoint, { reaction: slug })
const { data } = await axios.post(resolvedEndpoint, { reaction: slug })
setTotals(data.totals)
} catch {
setTotals((prev) => {
@@ -99,7 +105,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
setLoading(null)
}
},
[endpoint, isLoggedIn, loading],
[resolvedEndpoint, isLoggedIn, loading],
)
// Compute summary data
@@ -167,7 +173,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
aria-label={isArtworkVariant
? (myReaction
? `Open reaction picker. Current reaction: ${myReactionData?.label}.`
: 'Open reaction picker for this artwork')
: 'React to this artwork')
: (myReaction
? `You reacted with ${myReactionData?.label}. Click to remove.`
: 'React to this comment')}