diff --git a/frontend/src/components/CommunityCluesPanel.tsx b/frontend/src/components/CommunityCluesPanel.tsx new file mode 100644 index 0000000..b352b2e --- /dev/null +++ b/frontend/src/components/CommunityCluesPanel.tsx @@ -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([]) + const [newClue, setNewClue] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submissions, setSubmissions] = useState([]) + const [tab, setTab] = useState<'browse' | 'mine'>('browse') + const [searched, setSearched] = useState(false) + const [reportingId, setReportingId] = useState(null) + const [reportReason, setReportReason] = useState('') + const [reported, setReported] = useState>(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 ( + + 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 }}> +
+

+ + {t('community.title')} +

+ +
+ +
+ + +
+ + {tab === 'browse' ? ( + <> +
+ setWord(e.target.value.toUpperCase().replace(/[^A-Z]/g, ''))} + onKeyDown={e => e.key === 'Enter' && search()} + /> + +
+ + + {searched && word.trim() && ( + +
+ setNewClue(e.target.value)} + maxLength={500} + onKeyDown={e => e.key === 'Enter' && submitClue()} + /> + +
+ +
+ {clues.length === 0 && ( +

{t('community.noCluesYet', { word })}

+ )} + + {clues.map(c => ( + +
+ {c.clue} + {c.origin_domain && ( + {c.origin_domain} + )} +
+
+ + + {c.status !== 'pending' && ( + {c.status} + )} + {!reported.has(c.id) && ( + + + + )} + {reported.has(c.id) && ( + {t('common.reported')} + )} +
+ {reportingId === c.id && ( +
+ setReportReason(e.target.value)} + onKeyDown={e => e.key === 'Enter' && submitReport(c.id)} + maxLength={200} + /> + +
+ )} +
+ ))} +
+
+
+ )} +
+ + ) : ( +
+ {submissions.length === 0 && ( +

{t('community.noSubmissions')}

+ )} + + {submissions.map(s => ( + +
{s.word}
+
{s.clue}
+
+ {s.upvotes - s.downvotes > 0 ? '+' : ''}{s.upvotes - s.downvotes} + {s.status} + + + +
+
+ ))} +
+
+ )} +
+
+ ) +}