diff --git a/frontend/src/components/CluePanel.tsx b/frontend/src/components/CluePanel.tsx new file mode 100644 index 0000000..ee8e075 --- /dev/null +++ b/frontend/src/components/CluePanel.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { IconArrowRight, IconArrowDown, IconMessage2Filled, IconCheck, IconX } from '@tabler/icons-react' +import { motion, AnimatePresence } from 'motion/react' +import { useStore } from '../store' +import * as api from '../api' +import Tooltip from './ui/Tooltip' +import type { Direction } from '../types' + +export default function CluePanel() { + const puzzle = useStore((s) => s.puzzle) + const activeClue = useStore((s) => s.activeClue) + const direction = useStore((s) => s.direction) + const wordSpans = useStore((s) => s.wordSpans) + const selectCell = useStore((s) => s.selectCell) + const solved = useStore((s) => s.solved) + const entries = useStore((s) => s.entries) + const gameMode = useStore((s) => s.gameMode) + const listRef = useRef(null) + const [viewTab, setViewTab] = useState(direction) + const [editingClue, setEditingClue] = useState(null) + const [clueText, setClueText] = useState('') + const [submitting, setSubmitting] = useState(false) + const { t } = useTranslation() + const [submitted, setSubmitted] = useState>(new Set()) + + useEffect(() => { + setViewTab(direction) + }, [direction]) + + useEffect(() => { + if (!activeClue || !listRef.current) return + const el = listRef.current.querySelector('.clue-item.active') + if (el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + }, [activeClue, direction]) + + if (!puzzle) return null + + const clues = viewTab === 'across' ? puzzle.clues_across : puzzle.clues_down + + const isBlind = gameMode === 'blind' && !solved + + const isWordFilled = (num: number): boolean => { + const span = wordSpans.find(s => s.number === num && s.direction === viewTab) + if (!span) return false + return span.cells.every(([r, c]) => entries[r]?.[c]) + } + + const getWordForClue = (num: number): string => { + const span = wordSpans.find(s => s.number === num && s.direction === viewTab) + if (!span) return '' + return span.cells.map(([r, c]) => entries[r]?.[c] || '').join('') + } + + const onClueClick = (num: number) => { + const span = wordSpans.find( + (s) => s.number === num && s.direction === viewTab, + ) + if (span && span.cells.length > 0) { + selectCell(span.cells[0][0], span.cells[0][1]) + } + } + + const startEdit = (num: number) => { + setEditingClue(num) + setClueText('') + } + + const cancelEdit = () => { + setEditingClue(null) + setClueText('') + } + + const submitClue = async (num: number) => { + const word = getWordForClue(num) + if (!word || !clueText.trim()) return + setSubmitting(true) + try { + await api.submitClue(word, clueText.trim()) + setSubmitted(prev => new Set(prev).add(num)) + setEditingClue(null) + setClueText('') + } catch { /* ignored */ } + setSubmitting(false) + } + + return ( +
+
+ + +
+
+ {clues.map((clue) => ( +
+ + + {editingClue === clue.number && ( + e.stopPropagation()} + initial={{ height: 0, opacity: 0 }} + animate={{ height: 'auto', opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.2 }} + style={{ overflow: 'hidden' }} + > +
+ {getWordForClue(clue.number)} + setClueText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') submitClue(clue.number) + if (e.key === 'Escape') cancelEdit() + }} + maxLength={500} + autoFocus + /> +
+ + +
+
+
+ )} +
+
+ ))} +
+
+ ) +}