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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user