- 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
464 lines
19 KiB
JavaScript
464 lines
19 KiB
JavaScript
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>
|
|
)
|
|
}
|