initial commit (frontend)
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useLanguage } from '../i18n/LanguageContext';
|
||||
import { fonts, radius } from '../styles/theme';
|
||||
import { getNextTypingSentence, checkTypingSentence, getTypingHint, getTypingProgress, getTTS } from '../api';
|
||||
|
||||
interface TypingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TypingSentence {
|
||||
completed: boolean;
|
||||
sentence_id: number;
|
||||
translation: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const TypingModal: React.FC<TypingModalProps> = ({ isOpen, onClose }) => {
|
||||
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 isMountedRef = useRef(true);
|
||||
|
||||
const brandColor = '#2ECC71';
|
||||
const textColor = '#1A1A2E';
|
||||
const subColor = '#6B7280';
|
||||
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 stopAudio = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSentence = useCallback(() => {
|
||||
setLoading(true);
|
||||
setIsCorrect(null);
|
||||
setShowAnswer(false);
|
||||
setInputValue('');
|
||||
setCorrectAnswer('');
|
||||
setIsPlaying(false);
|
||||
stopAudio();
|
||||
|
||||
getNextTypingSentence()
|
||||
.then((data: any) => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (data.completed) {
|
||||
setSentence({ completed: true, sentence_id: 0, translation: '', level: '' });
|
||||
} else {
|
||||
setSentence(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current) setSentence(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMountedRef.current) setLoading(false);
|
||||
});
|
||||
|
||||
getTypingProgress()
|
||||
.then((data: any) => {
|
||||
if (isMountedRef.current) {
|
||||
setTotalCompleted(data.completed || 0);
|
||||
setTotalSentences(data.total || 0);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
isMountedRef.current = true;
|
||||
fetchSentence();
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
stopAudio();
|
||||
};
|
||||
}, [isOpen, 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(() => {
|
||||
if (isMountedRef.current) {
|
||||
fetchSentence();
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking sentence:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔧 ОБНОВЛЕНО: теперь это toggle.
|
||||
// - Если ответ уже показан — просто прячем, без нового запроса.
|
||||
// - Если скрыт — грузим через API и показываем.
|
||||
const handleShowAnswer = async () => {
|
||||
if (!sentence) return;
|
||||
|
||||
if (showAnswer) {
|
||||
// Уже показан — прячем обратно
|
||||
setShowAnswer(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ещё нет ответа — грузим и показываем
|
||||
if (!correctAnswer) {
|
||||
try {
|
||||
const data = await getTypingHint(sentence.sentence_id);
|
||||
setCorrectAnswer(data.correct_answer);
|
||||
} catch (e) {
|
||||
console.error('Error getting hint:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowAnswer(true);
|
||||
};
|
||||
|
||||
const playAnswer = async () => {
|
||||
if (!correctAnswer) return;
|
||||
stopAudio();
|
||||
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 (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(20,22,35,0.25)', zIndex: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '20px',
|
||||
backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes typingModalFadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
|
||||
@keyframes typingModalSlideUp { 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); } }
|
||||
@keyframes answerReveal { 0% { opacity: 0; transform: translateY(-6px); max-height: 0; } 100% { opacity: 1; transform: translateY(0); max-height: 200px; } }
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...glass({
|
||||
borderRadius: radius.lg,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 24px 60px rgba(31,38,73,0.20), inset 0 1px 0 rgba(255,255,255,0.85)',
|
||||
}),
|
||||
animation: 'typingModalSlideUp 0.3s ease',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Заголовок с кнопкой «Назад» слева */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '12px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
{/* 🔧 НОВОЕ: кнопка «Назад» — возвращает в меню учителя */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.95)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
← {t('back')}
|
||||
</button>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 800, color: textColor, whiteSpace: 'nowrap' }}>
|
||||
⌨️ {t('typingTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
...glass({ padding: '8px 14px', borderRadius: radius.sm }),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Прогресс */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px', color: subColor, fontSize: '12px' }}>
|
||||
{t('completed')}: {totalCompleted} / {totalSentences}
|
||||
</div>
|
||||
|
||||
{/* Блок показанного ответа — показывается только когда showAnswer === true */}
|
||||
{showAnswer && correctAnswer && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(46,204,113,0.08)',
|
||||
borderRadius: radius.md,
|
||||
marginBottom: '16px',
|
||||
border: '1px solid rgba(46,204,113,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
animation: 'answerReveal 0.25s ease',
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: brandColor, marginBottom: '4px' }}>
|
||||
✅ {t('typingShowAnswer')}
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
{correctAnswer}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={playAnswer}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${brandColor}40`,
|
||||
background: isPlaying ? `${brandColor}30` : 'rgba(255,255,255,0.9)',
|
||||
color: brandColor,
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isPlaying ? 'pulse 0.5s ease infinite' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : sentence?.completed ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px 10px' }}>
|
||||
<div style={{ fontSize: '56px', marginBottom: '12px' }}>🎉</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, color: textColor }}>
|
||||
{t('allSentencesCompleted')}
|
||||
</div>
|
||||
</div>
|
||||
) : sentence ? (
|
||||
<>
|
||||
{/* Перевод */}
|
||||
<div style={{
|
||||
...glass({
|
||||
padding: '18px',
|
||||
borderRadius: radius.lg,
|
||||
marginBottom: '16px',
|
||||
}),
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: subColor, marginBottom: '6px', fontWeight: 500 }}>
|
||||
{t('typingInstructions')}
|
||||
</div>
|
||||
<div style={{ fontSize: '18px', 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: '14px',
|
||||
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)' : 'rgba(255,255,255,0.85)',
|
||||
color: textColor,
|
||||
fontSize: '16px',
|
||||
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: '14px',
|
||||
padding: '10px',
|
||||
background: 'rgba(46,204,113,0.1)',
|
||||
borderRadius: radius.md,
|
||||
animation: 'correctPop 0.3s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: brandColor }}>
|
||||
✅ {t('typingCorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCorrect === false && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '14px',
|
||||
padding: '10px',
|
||||
background: 'rgba(231,76,60,0.1)',
|
||||
borderRadius: radius.md,
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E74C3C' }}>
|
||||
❌ {t('typingIncorrect')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '16px' }}>
|
||||
{isCorrect === true ? (
|
||||
<button
|
||||
onClick={fetchSentence}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: `linear-gradient(135deg, ${brandColor}, #27AE60)`,
|
||||
color: '#FFFFFF',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
boxShadow: `0 4px 15px ${brandColor}40`,
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{t('next')} →
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* 🔧 ОБНОВЛЕНО: toggle-кнопка «Показать ответ» / «Скрыть ответ» */}
|
||||
<button
|
||||
onClick={handleShowAnswer}
|
||||
style={{
|
||||
flex: 1,
|
||||
...glass({ padding: '14px', borderRadius: radius.lg }),
|
||||
color: showAnswer ? brandColor : textColor,
|
||||
border: showAnswer ? `1px solid ${brandColor}40` : '1px solid rgba(255,255,255,0.7)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onTouchStart={(e) => { e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
{showAnswer
|
||||
? `🙈 ${t('typingHideAnswer')}`
|
||||
: `👁️ ${t('typingShowAnswer')}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheck}
|
||||
disabled={!inputValue.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: radius.lg,
|
||||
border: 'none',
|
||||
background: inputValue.trim()
|
||||
? `linear-gradient(135deg, ${brandColor}, #27AE60)`
|
||||
: 'rgba(0,0,0,0.1)',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
cursor: inputValue.trim() ? 'pointer' : 'default',
|
||||
opacity: inputValue.trim() ? 1 : 0.5,
|
||||
}}
|
||||
onTouchStart={(e) => { if (inputValue.trim()) e.currentTarget.style.transform = 'scale(0.96)'; }}
|
||||
onTouchEnd={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
✓ {t('check')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '30px', color: subColor, fontSize: '14px' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingModal;
|
||||
Reference in New Issue
Block a user