feat: artwork page carousels, recommendations, avatars & fixes

- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
This commit is contained in:
2026-02-28 14:05:39 +01:00
parent 80100c7651
commit eee7df1f8c
46 changed files with 2536 additions and 498 deletions

View File

@@ -1,28 +1,181 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
import ReactMarkdown from 'react-markdown'
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
*/
/* ── Toolbar icon components ──────────────────────────────────────────────── */
function BoldIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
</svg>
)
}
function ItalicIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
)
}
function CodeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
)
}
function LinkIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)
}
function ListIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
function QuoteIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
</svg>
)
}
/* ── Toolbar button wrapper ───────────────────────────────────────────────── */
function ToolbarBtn({ title, onClick, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => { e.preventDefault(); onClick() }}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
>
{children}
</button>
)
}
/* ── Main component ───────────────────────────────────────────────────────── */
export default function CommentForm({
artworkId,
onPosted,
isLoggedIn = false,
loginUrl = '/login',
parentId = null,
replyTo = null,
onCancelReply = null,
compact = false,
}) {
const [content, setContent] = useState('')
const [tab, setTab] = useState('write') // 'write' | 'preview'
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState([])
const textareaRef = useRef(null)
const formRef = useRef(null)
// Insert text at current cursor position
// Auto-focus when entering reply mode
useEffect(() => {
if (replyTo && textareaRef.current) {
textareaRef.current.focus()
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [replyTo])
/* ── Helpers to wrap selected text ────────────────────────────────────── */
const wrapSelection = useCallback((before, after) => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const replacement = before + (selected || 'text') + after
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
const cursorPos = selected
? start + replacement.length
: start + before.length
const cursorEnd = selected
? start + replacement.length
: start + before.length + 4
el.selectionStart = cursorPos
el.selectionEnd = cursorEnd
el.focus()
})
}, [content])
const prefixLines = useCallback((prefix) => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const lines = selected ? selected.split('\n') : ['']
const prefixed = lines.map(l => prefix + l).join('\n')
const next = content.slice(0, start) + prefixed + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
el.selectionStart = start
el.selectionEnd = start + prefixed.length
el.focus()
})
}, [content])
const insertLink = useCallback(() => {
const el = textareaRef.current
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const selected = content.slice(start, end)
const isUrl = /^https?:\/\//.test(selected)
const replacement = isUrl
? `[link](${selected})`
: `[${selected || 'link'}](https://)`
const next = content.slice(0, start) + replacement + content.slice(end)
setContent(next)
requestAnimationFrame(() => {
if (isUrl) {
el.selectionStart = start + 1
el.selectionEnd = start + 5
} else {
const urlStart = start + replacement.length - 1
el.selectionStart = urlStart - 8
el.selectionEnd = urlStart - 1
}
el.focus()
})
}, [content])
// Insert text at cursor (for emoji picker)
const insertAtCursor = useCallback((text) => {
const el = textareaRef.current
if (!el) {
@@ -32,11 +185,9 @@ export default function CommentForm({
const start = el.selectionStart ?? content.length
const end = el.selectionEnd ?? content.length
const next = content.slice(0, start) + text + content.slice(end)
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
@@ -48,6 +199,34 @@ export default function CommentForm({
insertAtCursor(emoji)
}, [insertAtCursor])
/* ── Keyboard shortcuts ───────────────────────────────────────────────── */
const handleKeyDown = useCallback((e) => {
const mod = e.ctrlKey || e.metaKey
if (!mod) return
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault()
wrapSelection('**', '**')
break
case 'i':
e.preventDefault()
wrapSelection('*', '*')
break
case 'k':
e.preventDefault()
insertLink()
break
case 'e':
e.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [wrapSelection, insertLink])
/* ── Submit ───────────────────────────────────────────────────────────── */
const handleSubmit = useCallback(
async (e) => {
e.preventDefault()
@@ -66,14 +245,18 @@ export default function CommentForm({
try {
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
content: trimmed,
parent_id: parentId || null,
})
setContent('')
setTab('write')
onPosted?.(data.data)
onCancelReply?.()
} catch (err) {
if (err.response?.status === 422) {
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
const fieldErrors = err.response.data?.errors ?? {}
const allErrors = Object.values(fieldErrors).flat()
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
} else {
setErrors(['Something went wrong. Please try again.'])
}
@@ -81,62 +264,174 @@ export default function CommentForm({
setSubmitting(false)
}
},
[artworkId, content, isLoggedIn, loginUrl, onPosted],
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
)
/* ── Logged-out state ─────────────────────────────────────────────────── */
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 className="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4 backdrop-blur-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 shrink-0 text-white/25">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-sm text-white/40">
<a href={loginUrl} className="font-medium text-accent transition-colors hover:text-accent/80">
Sign in
</a>{' '}
to join the conversation.
</p>
</div>
)
}
/* ── Editor ───────────────────────────────────────────────────────────── */
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
<form id={parentId ? `reply-form-${parentId}` : 'comment-form'} ref={formRef} onSubmit={handleSubmit} className="space-y-3">
{/* Reply indicator */}
{replyTo && (
<div className="flex items-center gap-2 rounded-lg bg-accent/[0.06] px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5 text-accent/60 shrink-0">
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
</svg>
<span className="text-xs text-white/50">
Replying to <span className="font-semibold text-white/70">{replyTo}</span>
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
<button
type="button"
onClick={onCancelReply}
className="ml-auto text-[11px] font-medium text-white/30 transition-colors hover:text-white/60"
>
Cancel
</button>
</div>
</div>
)}
{/* Markdown hint */}
<p className="text-xs text-white/25 px-1">
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
</p>
<div className={`rounded-2xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
{/* ── Top bar: tabs + emoji ─────────────────────────────────────── */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
{/* Tabs */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'write'
? 'bg-white/[0.08] text-white'
: 'text-white/40 hover:text-white/60',
].join(' ')}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={[
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
tab === 'preview'
? 'bg-white/[0.08] text-white'
: 'text-white/40 hover:text-white/60',
].join(' ')}
>
Preview
</button>
</div>
<div className="flex items-center gap-1.5">
<span
className={[
'text-[11px] tabular-nums font-medium transition-colors',
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
].join(' ')}
>
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
</span>
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
</div>
</div>
{/* ── Formatting toolbar (write mode only) ──────────────────────── */}
{tab === 'write' && (
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
<BoldIcon />
</ToolbarBtn>
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
<ItalicIcon />
</ToolbarBtn>
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
<CodeIcon />
</ToolbarBtn>
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
<LinkIcon />
</ToolbarBtn>
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
<ListIcon />
</ToolbarBtn>
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
<QuoteIcon />
</ToolbarBtn>
</div>
)}
{/* ── Write tab ─────────────────────────────────────────────────── */}
{tab === 'write' && (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={replyTo ? `Reply to ${replyTo}` : 'Share your thoughts…'}
rows={compact ? 2 : 4}
maxLength={10000}
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"
/>
)}
{/* ── Preview tab ───────────────────────────────────────────────── */}
{tab === 'preview' && (
<div className="min-h-[7rem] px-4 py-3">
{content.trim() ? (
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-white/25 italic">Nothing to preview</p>
)}
</div>
)}
{/* ── Bottom hint ───────────────────────────────────────────────── */}
{tab === 'write' && (
<div className="px-4 pb-2">
<p className="text-[11px] text-white/15">
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
</p>
</div>
)}
</div>
{/* Errors */}
{errors.length > 0 && (
<ul className="space-y-1" role="alert">
<ul className="space-y-1 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-4 py-2.5" role="alert">
{errors.map((e, i) => (
<li key={i} className="text-xs text-red-400 px-1">
<li key={i} className="text-xs font-medium text-red-400">
{e}
</li>
))}
@@ -148,9 +443,19 @@ export default function CommentForm({
<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"
className="rounded-full bg-accent px-6 py-2 text-sm font-semibold text-white shadow-lg shadow-accent/20 transition-all duration-200 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-deep disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none"
>
{submitting ? 'Posting…' : 'Post comment'}
{submitting ? (
<span className="inline-flex items-center gap-2">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<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
</span>
) : (
'Post comment'
)}
</button>
</div>
</form>

View File

@@ -131,7 +131,7 @@ function CommentItem({ comment }) {
loading="lazy"
onError={(e) => {
e.currentTarget.onerror = null
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/avatars/default.webp'
e.currentTarget.src = commenter.avatar_fallback || 'https://files.skinbase.org/default/avatar_default.webp'
}}
/>
</a>

View File

@@ -1,42 +1,80 @@
import React, { useCallback, useOptimistic, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios'
/* ── Reaction definitions ────────────────────────────────────────────────── */
const REACTIONS = [
{ slug: 'thumbs_up', emoji: '👍', label: 'Like' },
{ slug: 'heart', emoji: '❤️', label: 'Love' },
{ slug: 'fire', emoji: '🔥', label: 'Fire' },
{ slug: 'laugh', emoji: '😂', label: 'Haha' },
{ slug: 'clap', emoji: '👏', label: 'Clap' },
{ slug: 'wow', emoji: '😮', label: 'Wow' },
]
/* ── Small heart outline icon for the trigger ─────────────────────────────── */
function HeartOutlineIcon({ className }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
)
}
/**
* Reaction bar for an artwork or comment.
* Facebook-style reaction bar.
*
* - Compact trigger button (heart icon or the user's reaction)
* - Floating picker that appears on hover/click with scale animation
* - Summary row showing unique reaction emoji + total count
*
* Props:
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean — if false, clicking shows a prompt
* entityType 'artwork' | 'comment'
* entityId number
* initialTotals Record<slug, { emoji, label, count, mine }>
* isLoggedIn boolean
*/
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
const [totals, setTotals] = useState(initialTotals)
const [loading, setLoading] = useState(null) // slug being toggled
const [loading, setLoading] = useState(null)
const [pickerOpen, setPickerOpen] = useState(false)
const containerRef = useRef(null)
const hoverTimeout = useRef(null)
const endpoint =
entityType === 'artwork'
? `/api/artworks/${entityId}/reactions`
: `/api/comments/${entityId}/reactions`
// Close picker when clicking outside
useEffect(() => {
if (!pickerOpen) return
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setPickerOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [pickerOpen])
const toggle = useCallback(
async (slug) => {
if (!isLoggedIn) {
window.location.href = '/login'
return
}
if (loading) return // prevent double-click
if (loading) return
setLoading(slug)
setPickerOpen(false)
// Optimistic update
setTotals((prev) => {
const entry = prev[slug] ?? { count: 0, mine: false }
const entry = prev[slug] ?? { count: 0, mine: false, emoji: REACTIONS.find(r => r.slug === slug)?.emoji, label: REACTIONS.find(r => r.slug === slug)?.label }
return {
...prev,
[slug]: {
...entry,
count: entry.mine ? entry.count - 1 : entry.count + 1,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
@@ -46,14 +84,13 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
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,
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
mine: !entry.mine,
},
}
@@ -65,46 +102,127 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
[endpoint, isLoggedIn, loading],
)
// Compute summary data
const entries = Object.entries(totals)
const activeReactions = entries.filter(([, info]) => info.count > 0)
const totalCount = activeReactions.reduce((sum, [, info]) => sum + info.count, 0)
const myReaction = entries.find(([, info]) => info.mine)?.[0] ?? null
const myReactionData = myReaction ? REACTIONS.find(r => r.slug === myReaction) : null
if (entries.length === 0) return null
// Hover handlers for desktop — open on hover with a small delay
const onMouseEnter = () => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = setTimeout(() => setPickerOpen(true), 200)
}
const onMouseLeave = () => {
clearTimeout(hoverTimeout.current)
hoverTimeout.current = setTimeout(() => setPickerOpen(false), 400)
}
return (
<div
role="group"
aria-label="Reactions"
className="flex flex-wrap items-center gap-1.5"
ref={containerRef}
className="flex items-center gap-2"
onMouseLeave={onMouseLeave}
>
{entries.map(([slug, info]) => {
const { emoji, label, count, mine } = info
const isProcessing = loading === slug
{/* ── Trigger button ──────────────────────────────────────────── */}
<div className="relative" onMouseEnter={onMouseEnter}>
<button
type="button"
onClick={() => {
if (myReaction) {
// Quick-toggle: remove own reaction
toggle(myReaction)
} else {
// Quick-like with thumbs_up
toggle('thumbs_up')
}
}}
className={[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
myReaction
? 'text-accent'
: 'text-white/40 hover:text-white/70',
].join(' ')}
aria-label={myReaction ? `You reacted with ${myReactionData?.label}. Click to remove.` : 'React to this comment'}
>
{myReaction ? (
<span className="text-base leading-none">{myReactionData?.emoji}</span>
) : (
<HeartOutlineIcon className="h-4 w-4" />
)}
<span>{myReaction ? myReactionData?.label : 'React'}</span>
</button>
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(' ')}
{/* ── Floating picker ─────────────────────────────────────── */}
{pickerOpen && (
<div
className="absolute bottom-full left-0 mb-2 z-[200] animate-in fade-in slide-in-from-bottom-2 duration-200"
onMouseEnter={() => { clearTimeout(hoverTimeout.current) }}
onMouseLeave={onMouseLeave}
>
<span aria-hidden="true">{emoji}</span>
<span className="tabular-nums font-medium">{count > 0 ? count : ''}</span>
<span className="sr-only">{label}</span>
</button>
)
})}
<div className="flex items-center gap-0.5 rounded-full bg-nova-800/95 border border-white/[0.1] px-2 py-1.5 shadow-xl shadow-black/40 backdrop-blur-xl">
{REACTIONS.map((r, i) => {
const isActive = totals[r.slug]?.mine
return (
<button
key={r.slug}
type="button"
onClick={() => toggle(r.slug)}
disabled={loading === r.slug}
aria-label={`${r.label}${isActive ? ' (selected)' : ''}`}
className={[
'group/reaction relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-200',
'hover:bg-white/[0.08] hover:scale-125 hover:-translate-y-1',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
'disabled:opacity-50',
isActive ? 'bg-white/[0.1] scale-110' : '',
].join(' ')}
style={{ animationDelay: `${i * 30}ms` }}
title={r.label}
>
<span className="text-xl leading-none transition-transform duration-150 group-hover/reaction:scale-110">
{r.emoji}
</span>
{/* Tooltip */}
<span className="pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 rounded bg-black/80 px-1.5 py-0.5 text-[10px] font-medium text-white/90 opacity-0 transition-opacity group-hover/reaction:opacity-100 whitespace-nowrap">
{r.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
{/* ── Summary: stacked emoji + count ───────────────────────── */}
{totalCount > 0 && (
<button
type="button"
onClick={() => setPickerOpen(v => !v)}
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary"
aria-label={`${totalCount} reaction${totalCount !== 1 ? 's' : ''}`}
>
{/* Stacked emoji circles (Facebook-style, max 3) */}
<span className="inline-flex items-center -space-x-1">
{activeReactions.slice(0, 3).map(([slug, info], i) => (
<span
key={slug}
className="relative flex items-center justify-center w-5 h-5 rounded-full bg-nova-700 border border-nova-800 text-xs leading-none"
style={{ zIndex: 3 - i }}
title={info.label}
>
{info.emoji}
</span>
))}
</span>
<span className="text-xs font-medium tabular-nums text-white/50 group-hover/summary:text-white/70 transition-colors">
{totalCount}
</span>
</button>
)}
</div>
)
}