494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useLanguage } from '../i18n/LanguageContext';
|
|
import { fonts, radius } from '../styles/theme';
|
|
import { getNextTypingSentence, checkTypingSentence, getTypingHint, getTypingProgress, getTTS } from '../api';
|
|
|
|
interface TypingSentence {
|
|
completed: boolean;
|
|
sentence_id: number;
|
|
translation: string;
|
|
level: string;
|
|
}
|
|
|
|
const TypingPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const { t } = useLanguage();
|
|
const [sentence, setSentence] = useState<TypingSentence | null>(null);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [isCorrect, setIsCorrect] = useState<boolean | null>(null);
|
|
const [showAnswer, setShowAnswer] = useState(false);
|
|
const [correctAnswer, setCorrectAnswer] = useState('');
|
|
const [totalCompleted, setTotalCompleted] = useState(0);
|
|
const [totalSentences, setTotalSentences] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const brandColor = '#2ECC71';
|
|
const textColor = '#1A1A2E';
|
|
const subColor = '#6B7280';
|
|
const bgColor = '#F4F5F7';
|
|
const cardBg = 'rgba(255,255,255,0.55)';
|
|
|
|
const glass = (extra: React.CSSProperties = {}): React.CSSProperties => ({
|
|
background: cardBg,
|
|
backdropFilter: 'blur(24px) saturate(180%)',
|
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
|
border: '1px solid rgba(255,255,255,0.7)',
|
|
boxShadow: '0 8px 32px rgba(31,38,73,0.10), inset 0 1px 0 rgba(255,255,255,0.85)',
|
|
...extra,
|
|
});
|
|
|
|
const fetchSentence = useCallback(() => {
|
|
setLoading(true);
|
|
setIsCorrect(null);
|
|
setShowAnswer(false);
|
|
setInputValue('');
|
|
setCorrectAnswer('');
|
|
setIsPlaying(false);
|
|
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
|
|
getNextTypingSentence()
|
|
.then((data: any) => {
|
|
if (data.completed) {
|
|
setSentence({ completed: true, sentence_id: 0, translation: '', level: '' });
|
|
} else {
|
|
setSentence(data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
navigate('/menu');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
|
|
getTypingProgress()
|
|
.then((data: any) => {
|
|
setTotalCompleted(data.completed || 0);
|
|
setTotalSentences(data.total || 0);
|
|
})
|
|
.catch(() => {});
|
|
}, [navigate]);
|
|
|
|
useEffect(() => {
|
|
fetchSentence();
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
if ('speechSynthesis' in window) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
};
|
|
}, [fetchSentence]);
|
|
|
|
const handleCheck = async () => {
|
|
if (!sentence || !inputValue.trim()) return;
|
|
|
|
try {
|
|
const data = await checkTypingSentence(sentence.sentence_id, inputValue);
|
|
setIsCorrect(data.correct);
|
|
setCorrectAnswer(data.correct_answer);
|
|
|
|
if (data.correct) {
|
|
setTotalCompleted(prev => prev + 1);
|
|
setTimeout(() => {
|
|
fetchSentence();
|
|
}, 1500);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error checking sentence:', e);
|
|
}
|
|
};
|
|
|
|
const handleShowAnswer = async () => {
|
|
if (!sentence) return;
|
|
try {
|
|
const data = await getTypingHint(sentence.sentence_id);
|
|
setCorrectAnswer(data.correct_answer);
|
|
setShowAnswer(true);
|
|
} catch (e) {
|
|
console.error('Error getting hint:', e);
|
|
}
|
|
};
|
|
|
|
const playAnswer = async () => {
|
|
if (!correctAnswer) return;
|
|
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
|
|
if ('speechSynthesis' in window) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
|
|
setIsPlaying(true);
|
|
|
|
try {
|
|
const audioBlob = await getTTS(correctAnswer, 'male');
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(audioUrl);
|
|
audioRef.current = audio;
|
|
|
|
audio.onended = () => setIsPlaying(false);
|
|
audio.onerror = () => setIsPlaying(false);
|
|
|
|
await audio.play();
|
|
} catch (e) {
|
|
console.error('Error playing TTS:', e);
|
|
setIsPlaying(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{
|
|
minHeight: '100vh',
|
|
background: bgColor,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: subColor,
|
|
fontFamily: fonts.main,
|
|
fontSize: '18px',
|
|
}}>
|
|
{t('loading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
minHeight: '100vh',
|
|
background: bgColor,
|
|
color: textColor,
|
|
fontFamily: fonts.main,
|
|
padding: '24px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}>
|
|
<div style={{ position: 'fixed', top: '-80px', right: '-60px', width: '260px', height: '260px', borderRadius: '50%', background: brandColor, opacity: 0.18, filter: 'blur(90px)', pointerEvents: 'none', zIndex: 0 }} />
|
|
|
|
<style>{`
|
|
@keyframes fadeInUp {
|
|
0% { transform: translateY(10px); opacity: 0; }
|
|
100% { transform: translateY(0); opacity: 1; }
|
|
}
|
|
@keyframes modalFadeIn {
|
|
0% { opacity: 0; }
|
|
100% { opacity: 1; }
|
|
}
|
|
@keyframes modalSlideUp {
|
|
0% { transform: translateY(20px); opacity: 0; }
|
|
100% { transform: translateY(0); opacity: 1; }
|
|
}
|
|
@keyframes correctPop {
|
|
0% { transform: scale(1); }
|
|
50% { transform: scale(1.05); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
@keyframes shake {
|
|
0%, 100% { transform: translateX(0); }
|
|
25% { transform: translateX(-5px); }
|
|
75% { transform: translateX(5px); }
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.1); }
|
|
}
|
|
`}</style>
|
|
|
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
|
|
|
{/* Модалка с ответом */}
|
|
{showAnswer && correctAnswer && (
|
|
<div
|
|
style={{
|
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
background: 'rgba(20,22,35,0.25)', zIndex: 500,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
animation: 'modalFadeIn 0.2s ease', padding: '20px',
|
|
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
|
}}
|
|
onClick={(e) => { if (e.target === e.currentTarget) setShowAnswer(false); }}
|
|
>
|
|
<div style={{
|
|
...glass({
|
|
borderRadius: radius.lg,
|
|
padding: '24px',
|
|
width: '100%',
|
|
maxWidth: '380px',
|
|
textAlign: 'center',
|
|
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
|
}),
|
|
animation: 'modalSlideUp 0.3s ease',
|
|
}}>
|
|
<div style={{ fontSize: '32px', marginBottom: '12px' }}>🇵🇱</div>
|
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 700, color: subColor }}>
|
|
{t('typingShowAnswer')}:
|
|
</h3>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '10px',
|
|
marginBottom: '20px',
|
|
flexWrap: 'wrap',
|
|
}}>
|
|
<div style={{ fontSize: '22px', fontWeight: 800, color: '#1A1A2E' }}>
|
|
{correctAnswer}
|
|
</div>
|
|
<button
|
|
onClick={playAnswer}
|
|
style={{
|
|
width: '40px',
|
|
height: '40px',
|
|
borderRadius: '50%',
|
|
border: `1px solid ${brandColor}40`,
|
|
background: isPlaying ? `${brandColor}30` : 'rgba(255,255,255,0.9)',
|
|
color: brandColor,
|
|
fontSize: '18px',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
animation: isPlaying ? 'pulse 0.5s ease infinite' : 'none',
|
|
flexShrink: 0,
|
|
boxShadow: '0 2px 8px rgba(31,38,73,0.08)',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.9)'; }}
|
|
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
|
>
|
|
🔊
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAnswer(false)}
|
|
style={{
|
|
width: '100%',
|
|
padding: '14px',
|
|
borderRadius: radius.md,
|
|
border: 'none',
|
|
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
|
color: '#FFFFFF',
|
|
fontSize: '15px',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t('teacherClose')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Заголовок */}
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
marginBottom: '20px',
|
|
animation: 'fadeInUp 0.3s ease',
|
|
}}>
|
|
<button
|
|
onClick={() => navigate('/menu')}
|
|
style={{
|
|
...glass({
|
|
padding: '10px 18px',
|
|
borderRadius: radius.md,
|
|
}),
|
|
color: textColor,
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'transform 0.2s ease',
|
|
}}
|
|
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
|
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
|
>
|
|
← {t('back')}
|
|
</button>
|
|
<h1 style={{ fontSize: '22px', fontWeight: 900, margin: 0, color: textColor, flex: 1, textAlign: 'center' }}>
|
|
⌨️ {t('typingTitle')}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Прогресс */}
|
|
<div style={{ textAlign: 'center', marginBottom: '20px', color: subColor, fontSize: '13px' }}>
|
|
{t('completed')}: {totalCompleted} / {totalSentences}
|
|
</div>
|
|
|
|
{sentence?.completed ? (
|
|
<div style={{ textAlign: 'center', padding: '60px 20px', animation: 'fadeInUp 0.3s ease' }}>
|
|
<div style={{ fontSize: '64px', marginBottom: '16px' }}>🎉</div>
|
|
<div style={{ fontSize: '18px', fontWeight: 700, color: textColor }}>
|
|
{t('allSentencesCompleted')}
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/menu')}
|
|
style={{
|
|
marginTop: '20px',
|
|
padding: '14px 28px',
|
|
borderRadius: radius.lg,
|
|
border: 'none',
|
|
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
|
color: '#FFFFFF',
|
|
fontSize: '15px',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t('back')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Перевод */}
|
|
<div style={{
|
|
...glass({
|
|
padding: '24px',
|
|
borderRadius: radius.lg,
|
|
marginBottom: '20px',
|
|
}),
|
|
animation: 'fadeInUp 0.3s ease 0.1s both',
|
|
}}>
|
|
<div style={{ fontSize: '13px', color: subColor, marginBottom: '8px', fontWeight: 500 }}>
|
|
{t('typingInstructions')}
|
|
</div>
|
|
<div style={{ fontSize: '20px', fontWeight: 700, color: brandColor, lineHeight: '1.4' }}>
|
|
{sentence?.translation}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Поле ввода */}
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
placeholder={t('typingPlaceholder')}
|
|
disabled={isCorrect === true}
|
|
style={{
|
|
width: '100%',
|
|
padding: '16px',
|
|
borderRadius: radius.lg,
|
|
border: isCorrect === false ? `2px solid #E74C3C` : isCorrect === true ? `2px solid #2ECC71` : '1px solid rgba(255,255,255,0.7)',
|
|
background: isCorrect === true ? 'rgba(46,204,113,0.1)' : cardBg,
|
|
color: textColor,
|
|
fontSize: '18px',
|
|
fontWeight: 600,
|
|
fontFamily: fonts.main,
|
|
outline: 'none',
|
|
textAlign: 'center',
|
|
transition: 'all 0.2s ease',
|
|
boxShadow: isCorrect === false ? '0 0 15px rgba(231,76,60,0.3)' : '0 2px 8px rgba(31,38,73,0.06)',
|
|
animation: isCorrect === false ? 'shake 0.4s ease' : 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Статус */}
|
|
{isCorrect === true && (
|
|
<div style={{
|
|
textAlign: 'center',
|
|
marginTop: '16px',
|
|
padding: '12px',
|
|
background: 'rgba(46,204,113,0.1)',
|
|
borderRadius: radius.md,
|
|
animation: 'correctPop 0.3s ease',
|
|
}}>
|
|
<span style={{ fontSize: '16px', fontWeight: 700, color: '#2ECC71' }}>
|
|
✅ {t('typingCorrect')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{isCorrect === false && (
|
|
<div style={{
|
|
textAlign: 'center',
|
|
marginTop: '16px',
|
|
padding: '12px',
|
|
background: 'rgba(231,76,60,0.1)',
|
|
borderRadius: radius.md,
|
|
}}>
|
|
<span style={{ fontSize: '16px', fontWeight: 700, color: '#E74C3C' }}>
|
|
❌ {t('typingIncorrect')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Кнопки */}
|
|
<div style={{ display: 'flex', gap: '10px', marginTop: '20px', animation: 'fadeInUp 0.3s ease 0.2s both' }}>
|
|
{isCorrect === true ? (
|
|
<button
|
|
onClick={fetchSentence}
|
|
style={{
|
|
flex: 1,
|
|
padding: '16px',
|
|
borderRadius: radius.lg,
|
|
border: 'none',
|
|
background: `linear-gradient(135deg, #2ECC71, #27AE60)`,
|
|
color: '#FFFFFF',
|
|
fontSize: '16px',
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t('next')} →
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={handleShowAnswer}
|
|
style={{
|
|
flex: 1,
|
|
...glass({
|
|
padding: '16px',
|
|
borderRadius: radius.lg,
|
|
}),
|
|
color: textColor,
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
👁️ {t('typingShowAnswer')}
|
|
</button>
|
|
<button
|
|
onClick={handleCheck}
|
|
disabled={!inputValue.trim()}
|
|
style={{
|
|
flex: 1,
|
|
padding: '16px',
|
|
borderRadius: radius.lg,
|
|
border: 'none',
|
|
background: inputValue.trim()
|
|
? `linear-gradient(135deg, #2ECC71, #27AE60)`
|
|
: 'rgba(0,0,0,0.1)',
|
|
color: '#FFFFFF',
|
|
fontSize: '16px',
|
|
fontWeight: 700,
|
|
cursor: inputValue.trim() ? 'pointer' : 'default',
|
|
opacity: inputValue.trim() ? 1 : 0.5,
|
|
}}
|
|
>
|
|
✓ {t('check')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TypingPage; |