community clue panel
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IconX, IconMessageFilled, IconThumbUpFilled, IconThumbDownFilled, IconSend2, IconSearch, IconTrashFilled, IconFlagFilled } from '@tabler/icons-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import * as api from '../api'
|
||||
import type { CommunityClueItem, ClueSubmissionItem } from '../api'
|
||||
import Tooltip from './ui/Tooltip'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
|
||||
export default function CommunityCluesPanel({ onClose }: { onClose: () => void }) {
|
||||
const trapRef = useFocusTrap(true)
|
||||
const { t } = useTranslation()
|
||||
const [word, setWord] = useState('')
|
||||
const [clues, setClues] = useState<CommunityClueItem[]>([])
|
||||
const [newClue, setNewClue] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submissions, setSubmissions] = useState<ClueSubmissionItem[]>([])
|
||||
const [tab, setTab] = useState<'browse' | 'mine'>('browse')
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [reportingId, setReportingId] = useState<number | null>(null)
|
||||
const [reportReason, setReportReason] = useState('')
|
||||
const [reported, setReported] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'mine') {
|
||||
api.fetchMySubmissions().then(setSubmissions)
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
const search = async () => {
|
||||
if (!word.trim()) return
|
||||
const d = await api.fetchCommunityClues(word.trim())
|
||||
setClues(d.clues || [])
|
||||
setSearched(true)
|
||||
}
|
||||
|
||||
const submitClue = async () => {
|
||||
if (!word.trim() || !newClue.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.submitClue(word.trim(), newClue.trim())
|
||||
setNewClue('')
|
||||
const d = await api.fetchCommunityClues(word.trim())
|
||||
setClues(d.clues || [])
|
||||
} catch { /* ignored */ }
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const vote = async (id: number, v: number) => {
|
||||
await api.voteClue(id, v)
|
||||
const d = await api.fetchCommunityClues(word.trim())
|
||||
setClues(d.clues || [])
|
||||
}
|
||||
|
||||
const deleteSubmission = async (id: number) => {
|
||||
await api.deleteSubmission(id)
|
||||
setSubmissions(prev => prev.filter(s => s.id !== id))
|
||||
}
|
||||
|
||||
const submitReport = async (id: number) => {
|
||||
try {
|
||||
await api.submitReport('clue', String(id), reportReason || undefined)
|
||||
setReported(prev => new Set(prev).add(id))
|
||||
} catch { /* ignored */ }
|
||||
setReportingId(null)
|
||||
setReportReason('')
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="settings-overlay" onClick={onClose}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<motion.div ref={trapRef} className="community-panel" onClick={e => e.stopPropagation()}
|
||||
role="dialog" aria-modal="true" aria-label={t('community.title')}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<div className="settings-header">
|
||||
<h3>
|
||||
<IconMessageFilled size={18} style={{ verticalAlign: -3, marginRight: 6 }} />
|
||||
{t('community.title')}
|
||||
</h3>
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
|
||||
</div>
|
||||
|
||||
<div className="cc-tabs">
|
||||
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : ''}`} onClick={() => setTab('browse')}>
|
||||
{t('community.browseSubmit')}
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'mine' ? 'btn-primary' : ''}`} onClick={() => setTab('mine')}>
|
||||
{t('community.mySubmissions')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'browse' ? (
|
||||
<>
|
||||
<div className="cc-submit-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('community.enterWord')}
|
||||
aria-label={t('community.enterWord')}
|
||||
value={word}
|
||||
onChange={e => setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))}
|
||||
onKeyDown={e => e.key === 'Enter' && search()}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={search} disabled={!word.trim()}>
|
||||
<IconSearch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{searched && word.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="cc-submit-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('community.writeClueFor', { word })}
|
||||
aria-label={t('community.writeClueFor', { word })}
|
||||
value={newClue}
|
||||
onChange={e => setNewClue(e.target.value)}
|
||||
maxLength={500}
|
||||
onKeyDown={e => e.key === 'Enter' && submitClue()}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={submitClue} disabled={submitting || !newClue.trim()}>
|
||||
<IconSend2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cc-list">
|
||||
{clues.length === 0 && (
|
||||
<p className="cc-empty">{t('community.noCluesYet', { word })}</p>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{clues.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
className={`cc-clue ${c.status === 'approved' ? 'cc-approved' : c.status === 'rejected' ? 'cc-rejected' : ''}`}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="cc-clue-text">
|
||||
{c.clue}
|
||||
{c.origin_domain && (
|
||||
<span className="cc-origin-badge">{c.origin_domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cc-clue-actions">
|
||||
<button
|
||||
className={`cc-vote ${c.my_vote === 1 ? 'cc-voted' : ''}`}
|
||||
onClick={() => vote(c.id, 1)}
|
||||
>
|
||||
<IconThumbUpFilled size={12} /> {c.upvotes}
|
||||
</button>
|
||||
<button
|
||||
className={`cc-vote ${c.my_vote === -1 ? 'cc-voted' : ''}`}
|
||||
onClick={() => vote(c.id, -1)}
|
||||
>
|
||||
<IconThumbDownFilled size={12} /> {c.downvotes}
|
||||
</button>
|
||||
{c.status !== 'pending' && (
|
||||
<span className={`cc-status cc-status-${c.status}`}>{c.status}</span>
|
||||
)}
|
||||
{!reported.has(c.id) && (
|
||||
<Tooltip text={t('community.reportClue')}>
|
||||
<button
|
||||
className="cc-report-btn"
|
||||
onClick={() => setReportingId(reportingId === c.id ? null : c.id)}
|
||||
>
|
||||
<IconFlagFilled size={10} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reported.has(c.id) && (
|
||||
<span className="cc-reported">{t('common.reported')}</span>
|
||||
)}
|
||||
</div>
|
||||
{reportingId === c.id && (
|
||||
<div className="cc-report-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('community.reasonPlaceholder')}
|
||||
aria-label={t('community.reasonPlaceholder')}
|
||||
value={reportReason}
|
||||
onChange={e => setReportReason(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && submitReport(c.id)}
|
||||
maxLength={200}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={() => submitReport(c.id)}>{t('community.submitReport')}</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
) : (
|
||||
<div className="cc-list">
|
||||
{submissions.length === 0 && (
|
||||
<p className="cc-empty">{t('community.noSubmissions')}</p>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{submissions.map(s => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
className="cc-clue"
|
||||
layout
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0, padding: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="cc-clue-word">{s.word}</div>
|
||||
<div className="cc-clue-text">{s.clue}</div>
|
||||
<div className="cc-clue-actions">
|
||||
<span className="cc-vote-count">{s.upvotes - s.downvotes > 0 ? '+' : ''}{s.upvotes - s.downvotes}</span>
|
||||
<span className={`cc-status cc-status-${s.status}`}>{s.status}</span>
|
||||
<Tooltip text={t('community.deleteSubmission')}>
|
||||
<button
|
||||
className="cc-delete"
|
||||
onClick={() => deleteSubmission(s.id)}
|
||||
>
|
||||
<IconTrashFilled size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user