community clue panel

This commit is contained in:
2026-04-03 12:22:19 +03:00
parent c4e28f2d77
commit 2e9f71323b
@@ -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>
)
}