messages implemented
This commit is contained in:
110
resources/js/components/comments/ReactionBar.jsx
Normal file
110
resources/js/components/comments/ReactionBar.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user