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