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