diff --git a/frontend/src/components/ClueTrainer.tsx b/frontend/src/components/ClueTrainer.tsx new file mode 100644 index 0000000..6e02ecf --- /dev/null +++ b/frontend/src/components/ClueTrainer.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { motion } from 'motion/react' +import { IconX, IconArrowRight, IconCheck, IconX as IconWrong } from '@tabler/icons-react' +import * as api from '../api' + +interface Props { + onClose: () => void +} + +type Difficulty = 'easy' | 'medium' | 'hard' + +export default function ClueTrainer({ onClose }: Props) { + const { t } = useTranslation() + const [difficulty, setDifficulty] = useState('easy') + const [clue, setClue] = useState('') + const [pattern, setPattern] = useState('') + const [wordLen, setWordLen] = useState(0) + const [answer, setAnswer] = useState('') + const [result, setResult] = useState<{ correct: boolean; answer: string } | null>(null) + const [correct, setCorrect] = useState(0) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const inputRef = useRef(null) + + const fetchClue = async (diff: Difficulty) => { + setLoading(true) + setResult(null) + setAnswer('') + try { + const data = await api.fetchTrainerClue(diff) + setClue(data.clue) + setPattern(data.pattern) + setWordLen(data.length) + } catch (_e) { + setClue(t('trainer.noClues')) + } + setLoading(false) + setTimeout(() => inputRef.current?.focus(), 50) + } + + useEffect(() => { + const t0 = setTimeout(() => fetchClue(difficulty), 0) + return () => clearTimeout(t0) + }, []) + + const handleSubmit = async () => { + if (!answer.trim() || result) return + try { + const res = await api.checkTrainerAnswer(clue, answer) + setResult(res) + setTotal(t => t + 1) + if (res.correct) setCorrect(c => c + 1) + } catch (_e) { + setResult({ correct: false, answer: '?' }) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (result) { + fetchClue(difficulty) + } else { + handleSubmit() + } + } + } + + const changeDifficulty = (d: Difficulty) => { + setDifficulty(d) + fetchClue(d) + } + + return ( + + e.stopPropagation()} + role="dialog" aria-modal="true" aria-label={t('trainer.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('trainer.title')}

+ {total > 0 && ( +

{t('trainer.score', { correct, total })}

+ )} +
+ +
+ +
+ {(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => ( + + ))} +
+ +
+ {loading ? ( +

{t('common.loading')}

+ ) : ( + <> +

{clue}

+ +
+ {t('trainer.letters', { count: wordLen })} + {pattern && {pattern}} +
+ +
+ setAnswer(e.target.value.toUpperCase())} + onKeyDown={handleKeyDown} + placeholder={t('trainer.placeholder')} + disabled={!!result} + maxLength={wordLen || 20} + autoComplete="off" + spellCheck={false} + /> + {!result ? ( + + ) : ( + + )} +
+ + {result && ( +
+ {result.correct ? ( + <> {t('trainer.correctMsg')} + ) : ( + <> {t('trainer.wrongMsg', { answer: result.answer })} + )} +
+ )} + + )} +
+
+
+ ) +}