159 lines
4.8 KiB
JavaScript
159 lines
4.8 KiB
JavaScript
import React, { useCallback, useRef, useState } from 'react'
|
|
import axios from 'axios'
|
|
import EmojiPickerButton from './EmojiPickerButton'
|
|
|
|
/**
|
|
* Comment form with emoji picker and Markdown-lite support.
|
|
*
|
|
* Props:
|
|
* artworkId number Target artwork
|
|
* onPosted (comment) => void Called when comment is successfully posted
|
|
* isLoggedIn boolean
|
|
* loginUrl string Where to redirect non-authenticated users
|
|
*/
|
|
export default function CommentForm({
|
|
artworkId,
|
|
onPosted,
|
|
isLoggedIn = false,
|
|
loginUrl = '/login',
|
|
}) {
|
|
const [content, setContent] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [errors, setErrors] = useState([])
|
|
const textareaRef = useRef(null)
|
|
|
|
// Insert text at current cursor position
|
|
const insertAtCursor = useCallback((text) => {
|
|
const el = textareaRef.current
|
|
if (!el) {
|
|
setContent((v) => v + text)
|
|
return
|
|
}
|
|
|
|
const start = el.selectionStart ?? content.length
|
|
const end = el.selectionEnd ?? content.length
|
|
|
|
const next = content.slice(0, start) + text + content.slice(end)
|
|
setContent(next)
|
|
|
|
// Restore cursor after the inserted text
|
|
requestAnimationFrame(() => {
|
|
el.selectionStart = start + text.length
|
|
el.selectionEnd = start + text.length
|
|
el.focus()
|
|
})
|
|
}, [content])
|
|
|
|
const handleEmojiSelect = useCallback((emoji) => {
|
|
insertAtCursor(emoji)
|
|
}, [insertAtCursor])
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e) => {
|
|
e.preventDefault()
|
|
|
|
if (!isLoggedIn) {
|
|
window.location.href = loginUrl
|
|
return
|
|
}
|
|
|
|
const trimmed = content.trim()
|
|
if (!trimmed) return
|
|
|
|
setSubmitting(true)
|
|
setErrors([])
|
|
|
|
try {
|
|
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
|
content: trimmed,
|
|
})
|
|
|
|
setContent('')
|
|
onPosted?.(data.data)
|
|
} catch (err) {
|
|
if (err.response?.status === 422) {
|
|
const apiErrors = err.response.data?.errors?.content ?? ['Invalid content.']
|
|
setErrors(Array.isArray(apiErrors) ? apiErrors : [apiErrors])
|
|
} else {
|
|
setErrors(['Something went wrong. Please try again.'])
|
|
}
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
},
|
|
[artworkId, content, isLoggedIn, loginUrl, onPosted],
|
|
)
|
|
|
|
if (!isLoggedIn) {
|
|
return (
|
|
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-5 py-4 text-sm text-white/50">
|
|
<a href={loginUrl} className="text-sky-400 hover:text-sky-300 font-medium transition-colors">
|
|
Sign in
|
|
</a>{' '}
|
|
to leave a comment.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-2">
|
|
{/* Textarea */}
|
|
<div className="relative rounded-xl border border-white/[0.1] bg-white/[0.03] focus-within:border-white/[0.2] focus-within:bg-white/[0.05] transition-colors">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
placeholder="Write a comment… Markdown supported: **bold**, *italic*, `code`"
|
|
rows={3}
|
|
maxLength={10000}
|
|
disabled={submitting}
|
|
aria-label="Comment text"
|
|
className="w-full resize-none bg-transparent px-4 pt-3 pb-10 text-sm text-white placeholder-white/25 focus:outline-none disabled:opacity-50"
|
|
/>
|
|
|
|
{/* Toolbar at bottom-right of textarea */}
|
|
<div className="absolute bottom-2 right-3 flex items-center gap-2">
|
|
<span
|
|
className={[
|
|
'text-xs tabular-nums transition-colors',
|
|
content.length > 9000 ? 'text-amber-400' : 'text-white/20',
|
|
].join(' ')}
|
|
aria-live="polite"
|
|
>
|
|
{content.length}/10 000
|
|
</span>
|
|
|
|
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Markdown hint */}
|
|
<p className="text-xs text-white/25 px-1">
|
|
**bold** · *italic* · `code` · https://links.auto-linked · @mentions
|
|
</p>
|
|
|
|
{/* Errors */}
|
|
{errors.length > 0 && (
|
|
<ul className="space-y-1" role="alert">
|
|
{errors.map((e, i) => (
|
|
<li key={i} className="text-xs text-red-400 px-1">
|
|
{e}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Submit */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={submitting || !content.trim()}
|
|
className="px-5 py-2 rounded-lg text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-colors disabled:opacity-40 disabled:pointer-events-none focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
|
>
|
|
{submitting ? 'Posting…' : 'Post comment'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|