Files
SkinbaseNova/resources/js/components/comments/ReactionBar.jsx
2026-02-26 21:12:32 +01:00

111 lines
3.3 KiB
JavaScript

import React, { useCallback, useOptimistic, useState } from 'react'
import axios from 'axios'
/**
* Reaction bar for an artwork or comment.
*
* Props:
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean — if false, clicking shows a prompt
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null) // slug being toggled
const endpoint =
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
const toggle = useCallback(
async (slug) => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (loading) return // prevent double-click
setLoading(slug)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
try {
const { data } = await axios.post(endpoint, { reaction: slug })
setTotals(data.totals)
} catch {
// Rollback
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
mine: !entry.mine,
},
}
})
} finally {
setLoading(null)
}
},
[endpoint, isLoggedIn, loading],
)
const entries = Object.entries(totals)
if (entries.length === 0) return null
return (
<div
role="group"
aria-label="Reactions"
className="flex flex-wrap items-center gap-1.5"
>
{entries.map(([slug, info]) => {
const { emoji, label, count, mine } = info
const isProcessing = loading === slug
return (
<button
key={slug}
type="button"
disabled={isProcessing}
onClick={() => toggle(slug)}
aria-label={`${label}${count} reaction${count !== 1 ? 's' : ''}${mine ? ' (your reaction)' : ''}`}
aria-pressed={mine}
className={[
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm',
'border transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
'disabled:opacity-50 disabled:pointer-events-none',
mine
? 'border-sky-500/60 bg-sky-500/15 text-sky-300 hover:bg-sky-500/25'
: 'border-white/[0.1] bg-white/[0.03] text-white/60 hover:border-white/20 hover:text-white/80',
]
.filter(Boolean)
.join(' ')}
>
<span aria-hidden="true">{emoji}</span>
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
<span className="sr-only">{label}</span>
</button>
)
})}
</div>
)
}