clue trainer

This commit is contained in:
2026-05-27 23:18:35 +03:00
parent 5e5b930a26
commit 1a085d9d22
+160
View File
@@ -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<Difficulty>('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<HTMLInputElement>(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 (
<motion.div className="settings-overlay" onClick={onClose}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div className="trainer-modal" onClick={e => 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 }}>
<div className="trainer-header">
<div>
<h3 className="trainer-title">{t('trainer.title')}</h3>
{total > 0 && (
<p className="trainer-score">{t('trainer.score', { correct, total })}</p>
)}
</div>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
<IconX size={16} />
</button>
</div>
<div className="trainer-difficulty">
{(['easy', 'medium', 'hard'] satisfies Difficulty[]).map(d => (
<button key={d}
className={`trainer-diff-btn ${difficulty === d ? 'trainer-diff-active' : ''}`}
onClick={() => changeDifficulty(d)}>
{t(`trainer.${d}`)}
</button>
))}
</div>
<div className="trainer-body">
{loading ? (
<p className="trainer-loading">{t('common.loading')}</p>
) : (
<>
<p className="trainer-clue">{clue}</p>
<div className="trainer-meta">
<span className="trainer-len">{t('trainer.letters', { count: wordLen })}</span>
{pattern && <span className="trainer-pattern">{pattern}</span>}
</div>
<div className="trainer-input-row">
<input
ref={inputRef}
type="text"
className="trainer-input"
value={answer}
onChange={e => setAnswer(e.target.value.toUpperCase())}
onKeyDown={handleKeyDown}
placeholder={t('trainer.placeholder')}
disabled={!!result}
maxLength={wordLen || 20}
autoComplete="off"
spellCheck={false}
/>
{!result ? (
<button className="btn btn-primary trainer-submit" onClick={handleSubmit}
disabled={!answer.trim()}>
{t('common.submit')}
</button>
) : (
<button className="btn btn-primary trainer-submit" onClick={() => fetchClue(difficulty)}>
<IconArrowRight size={14} /> {t('trainer.next')}
</button>
)}
</div>
{result && (
<div className={`trainer-result ${result.correct ? 'trainer-correct' : 'trainer-wrong'}`}>
{result.correct ? (
<><IconCheck size={16} /> {t('trainer.correctMsg')}</>
) : (
<><IconWrong size={16} /> {t('trainer.wrongMsg', { answer: result.answer })}</>
)}
</div>
)}
</>
)}
</div>
</motion.div>
</motion.div>
)
}