clue trainer
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user