messages implemented
This commit is contained in:
158
resources/js/components/comments/CommentForm.jsx
Normal file
158
resources/js/components/comments/CommentForm.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
|
||||
/**
|
||||
* Comment form with emoji picker and Markdown-lite support.
|
||||
*
|
||||
* Props:
|
||||
* artworkId number Target artwork
|
||||
* onPosted (comment) => void Called when comment is successfully posted
|
||||
* isLoggedIn boolean
|
||||
* loginUrl string Where to redirect non-authenticated users
|
||||
*/
|
||||
export default function CommentForm({
|
||||
artworkId,
|
||||
onPosted,
|
||||
isLoggedIn = false,
|
||||
loginUrl = '/login',
|
||||
}) {
|
||||
const [content, setContent] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
// Insert text at current cursor position
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const el = textareaRef.current
|
||||
if (!el) {
|
||||
setContent((v) => v + text)
|
||||
return
|
||||
}
|
||||
|
||||
const start = el.selectionStart ?? content.length
|
||||
const end = el.selectionEnd ?? content.length
|
||||
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
// Restore cursor after the inserted text
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start + text.length
|
||||
el.selectionEnd = start + text.length
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
insertAtCursor(emoji)
|
||||
}, [insertAtCursor])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = loginUrl
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setSubmitting(true)
|
||||
setErrors([])
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
||||
content: trimmed,
|
||||
})
|
||||
|
||||
setContent('')
|
||||
onPosted?.(data.data)
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422) {
|
||||
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
|
||||
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
|
||||
} else {
|
||||
setErrors(['Something went wrong. Please try again.'])
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted],
|
||||
)
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
|
||||
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
|
||||
Sign in
|
||||
</a>{' '}
|
||||
to leave a comment.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
{/* Textarea */}
|
||||
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
disabled={submitting}
|
||||
aria-label="Comment text"
|
||||
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* Toolbar at bottom-right of textarea */}
|
||||
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
'text-xs tabular-nums transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
|
||||
].join(' ')}
|
||||
aria-live="polite"
|
||||
>
|
||||
{content.length}/10 000
|
||||
</span>
|
||||
|
||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown hint */}
|
||||
<p className="text-xs text-white/25 px-1">
|
||||
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
|
||||
</p>
|
||||
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-1" role="alert">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i} className="text-xs text-red-400 px-1">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !content.trim()}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
||||
>
|
||||
{submitting ? 'Posting…' : 'Post comment'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
287
resources/js/components/comments/CommentsFeed.jsx
Normal file
287
resources/js/components/comments/CommentsFeed.jsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from 'react'
|
||||
|
||||
// ── Pagination ────────────────────────────────────────────────────────────────
|
||||
function Pagination({ meta, onPageChange }) {
|
||||
if (!meta || meta.last_page <= 1) return null
|
||||
|
||||
const { current_page, last_page } = meta
|
||||
const pages = []
|
||||
|
||||
if (last_page <= 7) {
|
||||
for (let i = 1; i <= last_page; i++) pages.push(i)
|
||||
} else {
|
||||
const around = new Set(
|
||||
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
|
||||
(p) => p >= 1 && p <= last_page
|
||||
)
|
||||
)
|
||||
const sorted = [...around].sort((a, b) => a - b)
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) pages.push('…')
|
||||
pages.push(sorted[i])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
|
||||
>
|
||||
<button
|
||||
disabled={current_page <= 1}
|
||||
onClick={() => onPageChange(current_page - 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '…' ? (
|
||||
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => p !== current_page && onPageChange(p)}
|
||||
aria-current={p === current_page ? 'page' : undefined}
|
||||
className={[
|
||||
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
p === current_page
|
||||
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={current_page >= last_page}
|
||||
onClick={() => onPageChange(current_page + 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pin icon (for artwork reference) ─────────────────────────────────────────
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 shrink-0 text-white/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Artwork image icon (for right panel label) ────────────────────────────────
|
||||
function ImageIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3 h-3 shrink-0 text-white/25"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single comment row ────────────────────────────────────────────────────────
|
||||
function CommentItem({ comment }) {
|
||||
const { commenter, artwork, comment_text, time_ago, created_at } = comment
|
||||
|
||||
return (
|
||||
<article className="flex gap-4 p-4 sm:p-5 rounded-xl border border-white/[0.065] bg-white/[0.025] hover:bg-white/[0.04] transition-colors">
|
||||
|
||||
{/* ── Avatar ── */}
|
||||
<a
|
||||
href={commenter.profile_url}
|
||||
className="shrink-0 mt-0.5"
|
||||
aria-label={`View ${commenter.display}'s profile`}
|
||||
>
|
||||
<img
|
||||
src={commenter.avatar_url}
|
||||
alt={commenter.display}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-full object-cover ring-1 ring-white/[0.1]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null
|
||||
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
{/* Author + time */}
|
||||
<div className="flex items-baseline gap-2 flex-wrap mb-1">
|
||||
<a
|
||||
href={commenter.profile_url}
|
||||
className="text-sm font-bold text-white hover:text-white/80 transition-colors"
|
||||
>
|
||||
{commenter.display}
|
||||
</a>
|
||||
<time
|
||||
dateTime={created_at}
|
||||
title={created_at ? new Date(created_at).toLocaleString() : ''}
|
||||
className="text-xs text-white/40"
|
||||
>
|
||||
{time_ago}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Comment text — primary visual element */}
|
||||
<p className="text-base text-white leading-relaxed whitespace-pre-line break-words mb-3">
|
||||
{comment_text}
|
||||
</p>
|
||||
|
||||
{/* Artwork reference link */}
|
||||
{artwork && (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-sky-400 hover:text-sky-300 transition-colors group"
|
||||
>
|
||||
<PinIcon />
|
||||
<span className="font-medium">{artwork.title}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: artwork thumbnail ── */}
|
||||
{artwork?.thumb && (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="shrink-0 self-start group"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="w-[220px] overflow-hidden rounded-lg ring-1 ring-white/[0.07] group-hover:ring-white/20 transition-all">
|
||||
<img
|
||||
src={artwork.thumb}
|
||||
alt={artwork.title ?? 'Artwork'}
|
||||
width={220}
|
||||
height={96}
|
||||
className="w-full h-24 object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.closest('a').style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 px-0.5">
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<ImageIcon />
|
||||
<span className="text-[11px] text-white/45 truncate max-w-[200px]">{artwork.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
||||
function FeedSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4 p-5 rounded-xl border border-white/[0.06] bg-white/[0.02]"
|
||||
>
|
||||
{/* avatar */}
|
||||
<div className="w-12 h-12 rounded-full bg-white/[0.07] shrink-0" />
|
||||
{/* content */}
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<div className="h-3 bg-white/[0.07] rounded w-32" />
|
||||
<div className="h-4 bg-white/[0.06] rounded w-full" />
|
||||
<div className="h-4 bg-white/[0.05] rounded w-3/4" />
|
||||
<div className="h-3 bg-white/[0.04] rounded w-24 mt-2" />
|
||||
</div>
|
||||
{/* thumbnail */}
|
||||
<div className="w-[220px] h-24 rounded-lg bg-white/[0.05] shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<svg
|
||||
className="mx-auto w-10 h-10 text-white/15 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 9.75a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 01.778-.332 48.294 48.294 0 005.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-white/30 text-sm">No comments found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
export default function CommentsFeed({
|
||||
comments = [],
|
||||
meta = {},
|
||||
loading = false,
|
||||
error = null,
|
||||
onPageChange,
|
||||
}) {
|
||||
if (loading) return <FeedSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-900/10 px-6 py-5 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!comments || comments.length === 0) return <EmptyState />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="feed" aria-live="polite" aria-busy={loading} className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.comment_id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination meta={meta} onPageChange={onPageChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
resources/js/components/comments/EmojiPickerButton.jsx
Normal file
92
resources/js/components/comments/EmojiPickerButton.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
/**
|
||||
* A button that opens a floating emoji picker.
|
||||
* When the user selects an emoji, `onEmojiSelect(emojiNative)` is called
|
||||
* with the native Unicode character.
|
||||
*
|
||||
* Props:
|
||||
* onEmojiSelect (string) → void Called with the emoji character
|
||||
* disabled boolean Disables the button
|
||||
* className string Additional classes for the trigger button
|
||||
*/
|
||||
export default function EmojiPickerButton({ onEmojiSelect, disabled = false, className = '' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleClick(e) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(emoji) => {
|
||||
onEmojiSelect?.(emoji.native)
|
||||
setOpen(false)
|
||||
},
|
||||
[onEmojiSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'flex items-center justify-center w-8 h-8 rounded-md',
|
||||
'text-white/40 hover:text-white/70 hover:bg-white/[0.07]',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500',
|
||||
'disabled:opacity-30 disabled:pointer-events-none',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 right-0 z-50 shadow-2xl rounded-xl overflow-hidden"
|
||||
style={{ filter: 'drop-shadow(0 8px 32px rgba(0,0,0,0.6))' }}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
perLine={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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