Save workspace changes
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
|
||||
/* ── 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)
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start + text.length
|
||||
el.selectionEnd = start + text.length
|
||||
el.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
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()
|
||||
|
||||
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,
|
||||
parent_id: parentId || null,
|
||||
})
|
||||
|
||||
setContent('')
|
||||
setTab('write')
|
||||
onPosted?.(data.data)
|
||||
onCancelReply?.()
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422) {
|
||||
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.'])
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
|
||||
)
|
||||
|
||||
/* ── Logged-out state ─────────────────────────────────────────────────── */
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<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 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>
|
||||
<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 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 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 font-medium text-red-400">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !content.trim()}
|
||||
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 ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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/default/avatar_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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import EmojiMartPicker from '../common/EmojiMartPicker'
|
||||
import loadEmojiMartData from '../common/loadEmojiMartData'
|
||||
|
||||
/**
|
||||
* 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 [pickerData, setPickerData] = useState(null)
|
||||
const wrapRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || pickerData) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
loadEmojiMartData().then((data) => {
|
||||
if (!cancelled) {
|
||||
setPickerData(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, pickerData])
|
||||
|
||||
// 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))' }}
|
||||
>
|
||||
{pickerData ? (
|
||||
<EmojiMartPicker
|
||||
data={pickerData}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
perLine={8}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-56 items-center justify-center bg-zinc-900 px-4 text-sm text-zinc-300">
|
||||
Loading emojis...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
||||
const [totals, setTotals] = useState(initialTotals)
|
||||
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
|
||||
setLoading(slug)
|
||||
setPickerOpen(false)
|
||||
|
||||
// Optimistic update
|
||||
setTotals((prev) => {
|
||||
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 ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(endpoint, { reaction: slug })
|
||||
setTotals(data.totals)
|
||||
} catch {
|
||||
setTotals((prev) => {
|
||||
const entry = prev[slug] ?? { count: 0, mine: false }
|
||||
return {
|
||||
...prev,
|
||||
[slug]: {
|
||||
...entry,
|
||||
count: entry.mine ? Math.max(0, entry.count - 1) : entry.count + 1,
|
||||
mine: !entry.mine,
|
||||
},
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
},
|
||||
[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
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
const isArtworkVariant = entityType === 'artwork'
|
||||
|
||||
const triggerClassName = isArtworkVariant
|
||||
? [
|
||||
'inline-flex items-center gap-2.5 rounded-full border px-4 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
myReaction
|
||||
? 'border-accent/35 bg-accent/12 text-accent shadow-[0_12px_30px_rgba(245,158,11,0.14)] hover:bg-accent/18'
|
||||
: 'border-white/[0.12] bg-white/[0.06] text-white/75 hover:border-accent/30 hover:bg-white/[0.1] hover:text-white',
|
||||
].join(' ')
|
||||
: [
|
||||
'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(' ')
|
||||
|
||||
const summaryClassName = isArtworkVariant
|
||||
? 'inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.05] px-3 py-1.5 transition-colors hover:border-white/[0.16] hover:bg-white/[0.08] group/summary'
|
||||
: 'inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5 transition-colors hover:bg-white/[0.06] group/summary'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={isArtworkVariant ? 'flex flex-wrap items-center gap-3' : 'flex items-center gap-2'}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* ── Trigger button ──────────────────────────────────────────── */}
|
||||
<div className="relative" onMouseEnter={onMouseEnter}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isArtworkVariant) {
|
||||
setPickerOpen((value) => !value)
|
||||
return
|
||||
}
|
||||
|
||||
if (myReaction) {
|
||||
toggle(myReaction)
|
||||
} else {
|
||||
toggle('thumbs_up')
|
||||
}
|
||||
}}
|
||||
className={triggerClassName}
|
||||
aria-label={isArtworkVariant
|
||||
? (myReaction
|
||||
? `Open reaction picker. Current reaction: ${myReactionData?.label}.`
|
||||
: 'Open reaction picker for this artwork')
|
||||
: (myReaction
|
||||
? `You reacted with ${myReactionData?.label}. Click to remove.`
|
||||
: 'React to this comment')}
|
||||
>
|
||||
{myReaction ? (
|
||||
<span className={isArtworkVariant ? 'text-xl leading-none' : 'text-base leading-none'}>{myReactionData?.emoji}</span>
|
||||
) : (
|
||||
<HeartOutlineIcon className={isArtworkVariant ? 'h-5 w-5' : 'h-4 w-4'} />
|
||||
)}
|
||||
<span>{myReaction ? myReactionData?.label : (isArtworkVariant ? 'React to this artwork' : 'React')}</span>
|
||||
</button>
|
||||
|
||||
{/* ── 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}
|
||||
>
|
||||
<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={summaryClassName}
|
||||
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