Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b14d050b | ||
|
|
7775541cdb |
172
src/App.tsx
172
src/App.tsx
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FlashCard } from './components/FlashCard';
|
||||
import { RatingButtons } from './components/RatingButtons';
|
||||
import { ProgressBar } from './components/ProgressBar';
|
||||
import { ImportModal } from './components/ImportModal';
|
||||
import { ImportModal, type ImportPayload } from './components/ImportModal';
|
||||
import { TtsSettingsModal } from './components/TtsSettingsModal';
|
||||
import { useAppStore } from './stores/appStore';
|
||||
|
||||
function App() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showTtsSettings, setShowTtsSettings] = useState(false);
|
||||
// Export functionality handled by direct download
|
||||
const {
|
||||
init,
|
||||
getCurrentWord,
|
||||
@@ -19,6 +18,7 @@ function App() {
|
||||
stats,
|
||||
getDueWords,
|
||||
addWords,
|
||||
importData,
|
||||
exportData,
|
||||
speak,
|
||||
isLoading,
|
||||
@@ -30,24 +30,45 @@ function App() {
|
||||
|
||||
const currentWord = getCurrentWord();
|
||||
const dueWords = getDueWords();
|
||||
const queuePreview = dueWords.slice(0, 4);
|
||||
|
||||
const categorySummary = useMemo(
|
||||
() =>
|
||||
queuePreview.reduce<Record<string, number>>((summary, word) => {
|
||||
summary[word.category] = (summary[word.category] || 0) + 1;
|
||||
return summary;
|
||||
}, {}),
|
||||
[queuePreview],
|
||||
);
|
||||
|
||||
const masteredRate = stats.totalWords > 0 ? Math.round((stats.masteredWords / stats.totalWords) * 100) : 0;
|
||||
|
||||
const handleExport = async () => {
|
||||
const data = await exportData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `french-vocab-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `french-vocab-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = async (payload: ImportPayload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
await addWords(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await importData(payload);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-indigo-600">
|
||||
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
<span className="text-xl font-semibold">Loading...</span>
|
||||
<span className="text-xl font-semibold">Loading study session...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -62,58 +83,55 @@ function App() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-3">Well Done!</h2>
|
||||
<p className="text-gray-600 mb-8">You have completed all due words for today.</p>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-3">Daily queue complete</h2>
|
||||
<p className="text-gray-600 mb-8">You have reviewed every due word. Import more vocabulary or export a backup.</p>
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-4 rounded-2xl">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.todayStudied}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Studied Today</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Reviewed today</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-4 rounded-2xl">
|
||||
<div className="text-3xl font-bold text-emerald-600">{stats.totalWords}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Total Words</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Words in deck</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => setShowImport(true)}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-bold py-3 rounded-2xl shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Continue
|
||||
Import words
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
onClick={handleExport}
|
||||
className="px-4 py-3 border-2 border-blue-500 text-blue-600 rounded-2xl hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Import
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportModal
|
||||
isOpen={showImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
onImport={addWords}
|
||||
/>
|
||||
<ImportModal isOpen={showImport} onClose={() => setShowImport(false)} onImport={handleImport} />
|
||||
|
||||
<TtsSettingsModal isOpen={showTtsSettings} onClose={() => setShowTtsSettings(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-blue-50 to-indigo-100 py-8 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="max-w-5xl mx-auto grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<header className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="inline-flex items-center gap-2 bg-white/80 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm">
|
||||
<span className="text-2xl">🇫🇷</span>
|
||||
<span className="text-gray-600 font-medium">French Vocabulary</span>
|
||||
<span className="text-gray-600 font-medium">French Vocabulary Coach</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowTtsSettings(true)}
|
||||
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
|
||||
title="发音设置"
|
||||
title="Pronunciation settings"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
@@ -122,7 +140,7 @@ function App() {
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
|
||||
title="导入词库"
|
||||
title="Import words or restore a backup"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
@@ -131,7 +149,7 @@ function App() {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
|
||||
title="导出备份"
|
||||
title="Export a backup"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
@@ -139,82 +157,108 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">Master French</h1>
|
||||
<p className="text-gray-500">Learn with spaced repetition</p>
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">Master French one card at a time</h1>
|
||||
<p className="text-gray-500">Spaced repetition, queue awareness, and local-first backups.</p>
|
||||
</header>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg text-center">
|
||||
<div className="text-3xl font-bold text-blue-500">{stats.todayStudied}</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Today</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Reviewed today</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg text-center">
|
||||
<div className="text-3xl font-bold text-emerald-500">{dueWords.length}</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Due</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Due now</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg text-center">
|
||||
<div className="text-3xl font-bold text-purple-500">{stats.totalWords}</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Total</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Words total</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg text-center">
|
||||
<div className="text-3xl font-bold text-amber-500">{masteredRate}%</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wide mt-1">Mastered</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-8">
|
||||
<ProgressBar
|
||||
current={stats.todayStudied}
|
||||
total={20}
|
||||
label="Daily Goal"
|
||||
/>
|
||||
<ProgressBar current={stats.todayStudied} total={20} label="Daily goal" />
|
||||
</div>
|
||||
|
||||
{/* Flash Card */}
|
||||
<div className="mb-6">
|
||||
<FlashCard
|
||||
word={currentWord}
|
||||
onFlip={flipCard}
|
||||
onSpeak={speak}
|
||||
/>
|
||||
<FlashCard word={currentWord} isFlipped={isFlipped} onFlip={flipCard} onSpeak={speak} />
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
{!isFlipped && (
|
||||
<div className="text-center mb-6">
|
||||
<span className="inline-flex items-center gap-2 bg-white/70 backdrop-blur-sm px-4 py-2 rounded-full text-gray-500 text-sm">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
Click card to reveal answer
|
||||
Click the card to reveal the translation
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating Buttons */}
|
||||
{isFlipped && (
|
||||
<RatingButtons onRate={rateWord} />
|
||||
)}
|
||||
{isFlipped && <RatingButtons onRate={rateWord} />}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/60 backdrop-blur-sm px-4 py-2 rounded-full text-sm text-gray-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Powered by SM-2 Algorithm • Data persisted locally
|
||||
Powered by the SM-2 review algorithm with local persistence
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<ImportModal
|
||||
isOpen={showImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
onImport={addWords}
|
||||
/>
|
||||
<aside className="bg-white/80 backdrop-blur-sm rounded-3xl shadow-xl p-6 h-fit">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Review queue</h2>
|
||||
<span className="text-sm text-gray-400">{dueWords.length} due</span>
|
||||
</div>
|
||||
|
||||
<TtsSettingsModal
|
||||
isOpen={showTtsSettings}
|
||||
onClose={() => setShowTtsSettings(false)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
{Object.entries(categorySummary).length > 0 ? (
|
||||
Object.entries(categorySummary).map(([category, count]) => (
|
||||
<span
|
||||
key={category}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 text-slate-600 text-sm"
|
||||
>
|
||||
<span>{category}</span>
|
||||
<span className="font-semibold">{count}</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">No due categories right now.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{queuePreview.length > 0 ? (
|
||||
queuePreview.map((word, index) => (
|
||||
<div key={word.id} className="rounded-2xl border border-slate-100 p-4 bg-white shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Queue #{index + 1}</p>
|
||||
<h3 className="text-lg font-semibold text-slate-800">{word.french}</h3>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-blue-50 text-blue-600">{word.category}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{word.english}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
Your queue is clear. Import more words to keep practicing.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ImportModal isOpen={showImport} onClose={() => setShowImport(false)} onImport={handleImport} />
|
||||
|
||||
<TtsSettingsModal isOpen={showTtsSettings} onClose={() => setShowTtsSettings(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { Word } from '../types/vocabulary';
|
||||
|
||||
interface FlashCardProps {
|
||||
word: Word;
|
||||
onFlip?: (isFlipped: boolean) => void;
|
||||
isFlipped: boolean;
|
||||
onFlip?: () => void;
|
||||
onSpeak?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
export function FlashCard({ word, isFlipped, onFlip, onSpeak }: FlashCardProps) {
|
||||
const handleFlip = () => {
|
||||
const newState = !isFlipped;
|
||||
setIsFlipped(newState);
|
||||
onFlip?.(newState);
|
||||
onFlip?.();
|
||||
};
|
||||
|
||||
const handleSpeakWord = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleSpeakWord = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onSpeak?.(word.ttsText || word.french);
|
||||
};
|
||||
|
||||
const handleSpeakExample = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleSpeakExample = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (word.example) {
|
||||
onSpeak?.(word.example);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full min-h-[32rem] cursor-pointer group"
|
||||
style={{ perspective: '1000px' }}
|
||||
onClick={handleFlip}
|
||||
>
|
||||
<div className="relative w-full min-h-[32rem] cursor-pointer group" style={{ perspective: '1000px' }} onClick={handleFlip}>
|
||||
<div
|
||||
className="relative w-full min-h-[32rem] transition-all duration-500 ease-out"
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* Front */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-sky-400 via-blue-500 to-indigo-600 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 text-white border-4 border-white/30"
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium">
|
||||
{word.category}
|
||||
</span>
|
||||
<span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium">{word.category}</span>
|
||||
{word.difficulty && (
|
||||
<span className="bg-white/15 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium capitalize">
|
||||
{word.difficulty}
|
||||
@@ -60,7 +50,7 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
<button
|
||||
onClick={handleSpeakWord}
|
||||
className="absolute top-4 left-4 p-2 bg-white/20 backdrop-blur-sm rounded-full hover:bg-white/30 transition-colors"
|
||||
title="播放单词发音"
|
||||
title="Play word pronunciation"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
@@ -69,18 +59,12 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
|
||||
<div className="text-center max-w-xl">
|
||||
<h2 className="text-5xl md:text-6xl font-bold mb-4 drop-shadow-lg break-words">{word.french}</h2>
|
||||
{word.partOfSpeech && (
|
||||
<div className="text-sm uppercase tracking-[0.2em] text-white/75 mb-3">{word.partOfSpeech}</div>
|
||||
)}
|
||||
{word.pronunciation && (
|
||||
<div className="text-xl opacity-90 font-light tracking-wide mb-3">/{word.pronunciation}/</div>
|
||||
)}
|
||||
{word.chinese && (
|
||||
<div className="text-lg text-white/90">{word.chinese}</div>
|
||||
)}
|
||||
{word.partOfSpeech && <div className="text-sm uppercase tracking-[0.2em] text-white/75 mb-3">{word.partOfSpeech}</div>}
|
||||
{word.pronunciation && <div className="text-xl opacity-90 font-light tracking-wide mb-3">/{word.pronunciation}/</div>}
|
||||
{word.chinese && <div className="text-lg text-white/90">{word.chinese}</div>}
|
||||
{word.tags && word.tags.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{word.tags.slice(0, 4).map(tag => (
|
||||
{word.tags.slice(0, 4).map((tag) => (
|
||||
<span key={tag} className="px-3 py-1 rounded-full bg-white/15 text-xs text-white/85">
|
||||
#{tag}
|
||||
</span>
|
||||
@@ -93,41 +77,36 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {
|
||||
<svg className="w-5 h-5 animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">点击翻转查看释义与例句</span>
|
||||
<span className="text-sm font-medium">Tap to reveal meaning and example</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-600 rounded-3xl shadow-2xl flex flex-col items-center justify-center p-8 text-white border-4 border-white/30"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)'
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl text-center space-y-5">
|
||||
<div>
|
||||
<div className="text-sm uppercase tracking-[0.25em] text-white/70 mb-2">Meaning</div>
|
||||
<h3 className="text-3xl md:text-4xl font-bold drop-shadow-lg">{word.english}</h3>
|
||||
{word.chinese && (
|
||||
<p className="mt-2 text-lg text-white/90">{word.chinese}</p>
|
||||
)}
|
||||
{word.chinese && <p className="mt-2 text-lg text-white/90">{word.chinese}</p>}
|
||||
</div>
|
||||
|
||||
{word.example && (
|
||||
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-left">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Exemple</div>
|
||||
<div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Example</div>
|
||||
<p className="text-lg italic leading-relaxed">"{word.example}"</p>
|
||||
{word.examplePronunciation && (
|
||||
<p className="mt-2 text-sm text-white/75">/{word.examplePronunciation}/</p>
|
||||
)}
|
||||
{word.examplePronunciation && <p className="mt-2 text-sm text-white/75">/{word.examplePronunciation}/</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSpeakExample}
|
||||
className="shrink-0 p-2 bg-white/15 backdrop-blur-sm rounded-full hover:bg-white/25 transition-colors"
|
||||
title="播放例句发音"
|
||||
title="Play example pronunciation"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { Word } from '../types/vocabulary';
|
||||
|
||||
export type ImportPayload = Word[] | { words: Word[]; progress?: unknown[]; stats?: unknown };
|
||||
|
||||
interface ImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (words: Word[]) => void;
|
||||
onImport: (payload: ImportPayload) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
|
||||
@@ -34,87 +37,111 @@ const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
|
||||
};
|
||||
};
|
||||
|
||||
const parseCsvLine = (line: string) =>
|
||||
line.split(',').map(part => part.trim().replace(/^"|"$/g, ''));
|
||||
const parseCsvLine = (line: string) => line.split(',').map((part) => part.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
const parseTags = (value: string | undefined) =>
|
||||
value
|
||||
?.split(/[|;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) || [];
|
||||
|
||||
export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
setContent(text);
|
||||
parseContent(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const parseContent = (text: string) => {
|
||||
const finishImport = async (payload: ImportPayload) => {
|
||||
setError('');
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
await onImport(payload);
|
||||
setContent('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed. Please check the file format.');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseContent = async (text: string) => {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
|
||||
if (Array.isArray(json)) {
|
||||
const words = json.map((item, index) => normalizeWord(item, index)).filter(Boolean) as Word[];
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (json.words && Array.isArray(json.words)) {
|
||||
const words = json.words.map((item: Partial<Word>, index: number) => normalizeWord(item, index)).filter(Boolean) as Word[];
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to CSV
|
||||
const words = json
|
||||
.map((item, index) => normalizeWord(item as Partial<Word>, index))
|
||||
.filter(Boolean) as Word[];
|
||||
|
||||
if (words.length === 0) {
|
||||
throw new Error('The JSON word list is empty.');
|
||||
}
|
||||
|
||||
await finishImport(words);
|
||||
return;
|
||||
}
|
||||
|
||||
if (json && typeof json === 'object' && Array.isArray((json as { words?: unknown[] }).words)) {
|
||||
const backup = json as { words: Partial<Word>[]; progress?: unknown[]; stats?: unknown };
|
||||
const words = backup.words
|
||||
.map((item, index) => normalizeWord(item, index))
|
||||
.filter(Boolean) as Word[];
|
||||
|
||||
if (words.length === 0) {
|
||||
throw new Error('The backup does not contain any valid words.');
|
||||
}
|
||||
|
||||
await finishImport({
|
||||
words,
|
||||
progress: Array.isArray(backup.progress) ? backup.progress : undefined,
|
||||
stats: backup.stats,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported JSON structure.');
|
||||
} catch (jsonError) {
|
||||
const lines = text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
setError('没有可导入的内容');
|
||||
return;
|
||||
throw jsonError instanceof Error ? jsonError : new Error('There is no importable content.');
|
||||
}
|
||||
|
||||
const firstLine = parseCsvLine(lines[0]).map(v => v.toLowerCase());
|
||||
const hasHeader = firstLine.includes('french') || firstLine.includes('english');
|
||||
const header = parseCsvLine(lines[0]).map((value) => value.toLowerCase());
|
||||
const hasHeader = header.includes('french') || header.includes('english');
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
const headerIndex = hasHeader ? firstLine : [];
|
||||
|
||||
const words = dataLines
|
||||
.map((line, index) => {
|
||||
const parts = parseCsvLine(line);
|
||||
|
||||
if (hasHeader) {
|
||||
const item: Partial<Word> = {
|
||||
french: parts[headerIndex.indexOf('french')] || '',
|
||||
english: parts[headerIndex.indexOf('english')] || '',
|
||||
pronunciation: parts[headerIndex.indexOf('pronunciation')] || '',
|
||||
chinese: parts[headerIndex.indexOf('chinese')] || '',
|
||||
partOfSpeech: parts[headerIndex.indexOf('partofspeech')] || '',
|
||||
example: parts[headerIndex.indexOf('example')] || '',
|
||||
exampleTranslation: parts[headerIndex.indexOf('exampletranslation')] || '',
|
||||
category: parts[headerIndex.indexOf('category')] || 'General',
|
||||
difficulty: (parts[headerIndex.indexOf('difficulty')] as Word['difficulty']) || 'beginner',
|
||||
};
|
||||
return normalizeWord(item, index);
|
||||
return normalizeWord(
|
||||
{
|
||||
french: parts[header.indexOf('french')] || '',
|
||||
english: parts[header.indexOf('english')] || '',
|
||||
chinese: parts[header.indexOf('chinese')] || '',
|
||||
pronunciation: parts[header.indexOf('pronunciation')] || '',
|
||||
ttsText: parts[header.indexOf('ttstext')] || '',
|
||||
partOfSpeech: parts[header.indexOf('partofspeech')] || '',
|
||||
category: parts[header.indexOf('category')] || 'General',
|
||||
example: parts[header.indexOf('example')] || '',
|
||||
exampleTranslation: parts[header.indexOf('exampletranslation')] || '',
|
||||
examplePronunciation: parts[header.indexOf('examplepronunciation')] || '',
|
||||
notes: parts[header.indexOf('notes')] || '',
|
||||
audioUrl: parts[header.indexOf('audiourl')] || '',
|
||||
difficulty: (parts[header.indexOf('difficulty')] as Word['difficulty']) || 'beginner',
|
||||
tags: parseTags(parts[header.indexOf('tags')]),
|
||||
},
|
||||
index,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeWord(
|
||||
@@ -123,45 +150,59 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
english: parts[1] || '',
|
||||
pronunciation: parts[2] || '',
|
||||
category: parts[3] || 'General',
|
||||
example: parts[4] || '',
|
||||
exampleTranslation: parts[5] || '',
|
||||
difficulty: (parts[4] as Word['difficulty']) || 'beginner',
|
||||
chinese: parts[5] || '',
|
||||
example: parts[6] || '',
|
||||
exampleTranslation: parts[7] || '',
|
||||
tags: parseTags(parts[8]),
|
||||
},
|
||||
index
|
||||
index,
|
||||
);
|
||||
})
|
||||
.filter(Boolean) as Word[];
|
||||
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
} else {
|
||||
setError('无法解析文件格式,请检查 french 和 english 字段是否存在');
|
||||
if (words.length === 0) {
|
||||
throw jsonError instanceof Error ? jsonError : new Error('Unable to parse the provided content.');
|
||||
}
|
||||
|
||||
await finishImport(words);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (loadEvent) => {
|
||||
const text = String(loadEvent.target?.result || '');
|
||||
setContent(text);
|
||||
await parseContent(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim()) {
|
||||
setError('请输入内容');
|
||||
setError('Please paste content or choose a file first.');
|
||||
return;
|
||||
}
|
||||
parseContent(content);
|
||||
|
||||
await parseContent(content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">导入词库</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">Import vocabulary</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
支持更完整的法语词条字段:单词、英文、中文、音标、词性、例句、例句翻译、分类、难度。
|
||||
Supports CSV, JSON word lists, and full backup files with words, progress, and stats.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">
|
||||
上传文件 (CSV 或 JSON)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Upload a CSV or JSON file</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileUpload}
|
||||
@@ -171,22 +212,25 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">或粘贴内容</span>
|
||||
<span className="px-2 bg-white text-gray-500">or paste content</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`CSV 表头示例:
|
||||
french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty
|
||||
bonjour,hello,你好,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,大家好。,beginner
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={`CSV header example:
|
||||
french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty,tags
|
||||
bonjour,hello,hello,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,Hello everyone.,beginner,daily|greeting
|
||||
|
||||
JSON 示例:
|
||||
[{"french":"bonjour","english":"hello","chinese":"你好","pronunciation":"bɔ̃ʒuʁ","partOfSpeech":"interjection","example":"Bonjour, comment ça va ?","exampleTranslation":"你好,最近怎么样?"}]`}
|
||||
JSON word list:
|
||||
[{"french":"bonjour","english":"hello","category":"Greetings"}]
|
||||
|
||||
Full backup:
|
||||
{"words":[...],"progress":[...],"stats":{...}}`}
|
||||
className="w-full h-56 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -197,13 +241,14 @@ JSON 示例:
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:shadow-lg transition-all"
|
||||
disabled={isImporting}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:shadow-lg transition-all disabled:opacity-60"
|
||||
>
|
||||
导入
|
||||
{isImporting ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,58 +18,61 @@ interface ButtonConfig {
|
||||
const BUTTONS: ButtonConfig[] = [
|
||||
{
|
||||
rating: 'again',
|
||||
label: '重来',
|
||||
sublabel: '< 1 分钟',
|
||||
label: 'Again',
|
||||
sublabel: '< 10 min',
|
||||
shortcut: '1',
|
||||
color: 'from-rose-400 to-red-500',
|
||||
shadow: 'shadow-rose-200'
|
||||
shadow: 'shadow-rose-200',
|
||||
},
|
||||
{
|
||||
rating: 'hard',
|
||||
label: '困难',
|
||||
sublabel: '~ 2 天',
|
||||
label: 'Hard',
|
||||
sublabel: '~ 1 day',
|
||||
shortcut: '2',
|
||||
color: 'from-orange-400 to-amber-500',
|
||||
shadow: 'shadow-orange-200'
|
||||
shadow: 'shadow-orange-200',
|
||||
},
|
||||
{
|
||||
rating: 'good',
|
||||
label: '良好',
|
||||
sublabel: '~ 4 天',
|
||||
label: 'Good',
|
||||
sublabel: '~ 3 days',
|
||||
shortcut: '3',
|
||||
color: 'from-blue-400 to-indigo-500',
|
||||
shadow: 'shadow-blue-200'
|
||||
shadow: 'shadow-blue-200',
|
||||
},
|
||||
{
|
||||
rating: 'easy',
|
||||
label: '简单',
|
||||
sublabel: '~ 7 天',
|
||||
label: 'Easy',
|
||||
sublabel: '~ 1 week',
|
||||
shortcut: '4',
|
||||
color: 'from-emerald-400 to-teal-500',
|
||||
shadow: 'shadow-emerald-200'
|
||||
shadow: 'shadow-emerald-200',
|
||||
},
|
||||
];
|
||||
|
||||
export function RatingButtons({ onRate, disabled }: RatingButtonsProps) {
|
||||
// 键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyMap: Record<string, DifficultyRating> = {
|
||||
'1': 'again',
|
||||
'2': 'hard',
|
||||
'3': 'good',
|
||||
'4': 'easy',
|
||||
'Enter': 'good',
|
||||
Enter: 'good',
|
||||
' ': 'good',
|
||||
};
|
||||
|
||||
const rating = keyMap[e.key];
|
||||
if (rating) {
|
||||
e.preventDefault();
|
||||
onRate(rating);
|
||||
const rating = keyMap[event.key];
|
||||
if (!rating) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onRate(rating);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
@@ -10,52 +10,53 @@ export function TtsSettingsModal({ isOpen, onClose }: TtsSettingsModalProps) {
|
||||
const { tts, availableVoices, setTtsSettings, refreshVoices } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshVoices();
|
||||
}, [isOpen, refreshVoices]);
|
||||
|
||||
const frenchVoices = useMemo(
|
||||
() => availableVoices.filter(v => v.lang?.toLowerCase().startsWith('fr')),
|
||||
[availableVoices]
|
||||
() => availableVoices.filter((voice) => voice.lang?.toLowerCase().startsWith('fr')),
|
||||
[availableVoices],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full p-6">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">发音设置</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">优先使用法语语音(fr-FR / fr)。</p>
|
||||
<h2 className="text-2xl font-bold text-gray-800">Pronunciation settings</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Prefer French voices when available, or fall back to the browser default.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 text-gray-500"
|
||||
aria-label="关闭"
|
||||
>
|
||||
✕
|
||||
<button onClick={onClose} className="p-2 rounded-xl hover:bg-gray-100 text-gray-500" aria-label="Close settings">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">语音(Voice)</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Voice</label>
|
||||
<select
|
||||
value={tts.voiceURI || ''}
|
||||
onChange={(e) => setTtsSettings({ voiceURI: e.target.value || undefined })}
|
||||
onChange={(event) => setTtsSettings({ voiceURI: event.target.value || undefined })}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">自动选择(推荐 fr-FR)</option>
|
||||
<option value="">Auto-select a French voice</option>
|
||||
{frenchVoices.length > 0 ? (
|
||||
frenchVoices.map(v => (
|
||||
<option key={v.voiceURI} value={v.voiceURI}>
|
||||
{v.name} — {v.lang}
|
||||
frenchVoices.map((voice) => (
|
||||
<option key={voice.voiceURI} value={voice.voiceURI}>
|
||||
{voice.name} - {voice.lang}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
(未检测到法语语音,仍可用自动模式)
|
||||
No French voice detected yet
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
@@ -63,27 +64,27 @@ export function TtsSettingsModal({ isOpen, onClose }: TtsSettingsModalProps) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">语速</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Speed</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={1.2}
|
||||
step={0.05}
|
||||
value={tts.rate}
|
||||
onChange={(e) => setTtsSettings({ rate: Number(e.target.value) })}
|
||||
onChange={(event) => setTtsSettings({ rate: Number(event.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">{tts.rate.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">音高</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Pitch</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
max={1.2}
|
||||
step={0.05}
|
||||
value={tts.pitch}
|
||||
onChange={(e) => setTtsSettings({ pitch: Number(e.target.value) })}
|
||||
onChange={(event) => setTtsSettings({ pitch: Number(event.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">{tts.pitch.toFixed(2)}</div>
|
||||
@@ -95,36 +96,38 @@ export function TtsSettingsModal({ isOpen, onClose }: TtsSettingsModalProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tts.autoSpeakWord}
|
||||
onChange={(e) => setTtsSettings({ autoSpeakWord: e.target.checked })}
|
||||
onChange={(event) => setTtsSettings({ autoSpeakWord: event.target.checked })}
|
||||
/>
|
||||
翻到正面自动读单词
|
||||
Auto-play the word
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tts.autoSpeakExample}
|
||||
onChange={(e) => setTtsSettings({ autoSpeakExample: e.target.checked })}
|
||||
onChange={(event) => setTtsSettings({ autoSpeakExample: event.target.checked })}
|
||||
/>
|
||||
翻到背面自动读例句
|
||||
Auto-play the example
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => window.speechSynthesis && window.speechSynthesis.cancel()}
|
||||
onClick={() => {
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
停止朗读
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Quick test
|
||||
const text = 'Bonjour. Je parle français.';
|
||||
useAppStore.getState().speak(text);
|
||||
useAppStore.getState().speak('Bonjour. Je parle francais.');
|
||||
}}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:shadow-lg transition-all"
|
||||
>
|
||||
试听
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { TtsSettings } from '../types/settings';
|
||||
import type { Word, StudyProgress, StudyStats } from '../types/vocabulary';
|
||||
|
||||
export interface WordEntry extends Word {
|
||||
@@ -9,11 +10,32 @@ export interface ProgressEntry extends StudyProgress {
|
||||
syncedAt?: Date;
|
||||
}
|
||||
|
||||
interface SettingsEntry {
|
||||
key: string;
|
||||
value: TtsSettings;
|
||||
}
|
||||
|
||||
type ImportedWordEntry = Omit<Word, 'addedAt'> & {
|
||||
addedAt?: Date | string;
|
||||
};
|
||||
|
||||
interface ImportedProgressEntry extends Omit<StudyProgress, 'nextReviewDate' | 'lastStudiedDate'> {
|
||||
nextReviewDate: Date | string;
|
||||
lastStudiedDate: Date | string;
|
||||
syncedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface BackupPayload {
|
||||
words?: ImportedWordEntry[];
|
||||
progress?: ImportedProgressEntry[];
|
||||
stats?: StudyStats;
|
||||
}
|
||||
|
||||
export class FrenchVocabDB extends Dexie {
|
||||
words!: Table<WordEntry>;
|
||||
progress!: Table<ProgressEntry>;
|
||||
stats!: Table<StudyStats & { id: string }>;
|
||||
settings!: Table<{ key: string; value: any }>;
|
||||
settings!: Table<SettingsEntry>;
|
||||
|
||||
constructor() {
|
||||
super('FrenchVocabDB');
|
||||
@@ -564,16 +586,36 @@ export async function exportData() {
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
export async function importData(data: any) {
|
||||
if (data.words) {
|
||||
const normalizeWordEntry = (word: ImportedWordEntry): WordEntry => ({
|
||||
...word,
|
||||
addedAt: word.addedAt ? new Date(word.addedAt) : new Date(),
|
||||
});
|
||||
|
||||
const normalizeProgressEntry = (progress: ImportedProgressEntry): ProgressEntry => ({
|
||||
...progress,
|
||||
nextReviewDate: new Date(progress.nextReviewDate),
|
||||
lastStudiedDate: new Date(progress.lastStudiedDate),
|
||||
syncedAt: progress.syncedAt ? new Date(progress.syncedAt) : undefined,
|
||||
});
|
||||
|
||||
export async function importData(data: unknown) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid backup data.');
|
||||
}
|
||||
|
||||
const backup = data as BackupPayload;
|
||||
|
||||
if (Array.isArray(backup.words)) {
|
||||
await db.words.clear();
|
||||
await db.words.bulkAdd(data.words);
|
||||
await db.words.bulkAdd(backup.words.map(normalizeWordEntry));
|
||||
}
|
||||
if (data.progress) {
|
||||
|
||||
if (Array.isArray(backup.progress)) {
|
||||
await db.progress.clear();
|
||||
await db.progress.bulkAdd(data.progress);
|
||||
await db.progress.bulkAdd(backup.progress.map(normalizeProgressEntry));
|
||||
}
|
||||
if (data.stats) {
|
||||
await db.stats.put({ ...data.stats, id: 'main' });
|
||||
|
||||
if (backup.stats) {
|
||||
await db.stats.put({ ...backup.stats, id: 'main' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { Word, StudyProgress, StudyStats, DifficultyRating, CategoryStats }
|
||||
import type { TtsSettings } from '../types/settings';
|
||||
import { DEFAULT_TTS_SETTINGS } from '../types/settings';
|
||||
import { calculateNextReview, calculateMastery } from '../utils/srs';
|
||||
import { db, initDatabase, exportData, importData } from '../db/database';
|
||||
import { db, initDatabase, exportData as exportBackupData, importData as importBackupData } from '../db/database';
|
||||
|
||||
type PersistedStats = StudyStats & { id: string };
|
||||
type VoiceOption = { name: string; lang: string; voiceURI: string };
|
||||
|
||||
interface AppState {
|
||||
words: Word[];
|
||||
@@ -15,9 +18,7 @@ interface AppState {
|
||||
selectedCategory: string | 'all';
|
||||
searchQuery: string;
|
||||
tts: TtsSettings;
|
||||
availableVoices: { name: string; lang: string; voiceURI: string }[];
|
||||
|
||||
// Actions
|
||||
availableVoices: VoiceOption[];
|
||||
init: () => Promise<void>;
|
||||
setWords: (words: Word[]) => void;
|
||||
addWords: (words: Word[]) => Promise<void>;
|
||||
@@ -28,25 +29,23 @@ interface AppState {
|
||||
getCurrentWord: () => Word | null;
|
||||
getDueWords: () => Word[];
|
||||
getFilteredWords: () => Word[];
|
||||
exportData: () => Promise<any>;
|
||||
importData: (data: any) => Promise<void>;
|
||||
exportData: () => Promise<unknown>;
|
||||
importData: (data: unknown) => Promise<void>;
|
||||
speak: (text: string) => void;
|
||||
setCategory: (category: string | 'all') => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
resetProgress: () => Promise<void>;
|
||||
getCategoryStats: () => CategoryStats[];
|
||||
|
||||
loadSettings: () => Promise<void>;
|
||||
setTtsSettings: (patch: Partial<TtsSettings>) => Promise<void>;
|
||||
refreshVoices: () => void;
|
||||
}
|
||||
|
||||
const DAILY_GOAL = 20;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MASTERED_THRESHOLD = 80;
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
words: [],
|
||||
progress: new Map(),
|
||||
stats: {
|
||||
const emptyStats: StudyStats = {
|
||||
totalWords: 0,
|
||||
masteredWords: 0,
|
||||
studyingWords: 0,
|
||||
@@ -55,8 +54,161 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
todayNewWords: 0,
|
||||
weeklyGoal: DAILY_GOAL * 7,
|
||||
weeklyProgress: 0,
|
||||
},
|
||||
currentWordIndex: 0,
|
||||
};
|
||||
|
||||
const getStartOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
const getDayDifference = (current: Date, previous?: Date) => {
|
||||
if (!previous) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor((getStartOfDay(current).getTime() - getStartOfDay(previous).getTime()) / DAY_MS);
|
||||
};
|
||||
|
||||
const getMastery = (progress: StudyProgress) => progress.mastery ?? calculateMastery(progress);
|
||||
|
||||
const withMastery = (progress: StudyProgress): StudyProgress => ({
|
||||
...progress,
|
||||
mastery: calculateMastery(progress),
|
||||
});
|
||||
|
||||
const getPersistedStatsOverrides = (statsFromDB?: PersistedStats): Partial<StudyStats> => {
|
||||
if (!statsFromDB) {
|
||||
return {
|
||||
weeklyGoal: DAILY_GOAL * 7,
|
||||
weeklyProgress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const lastStudyDate = statsFromDB.lastStudyDate ? new Date(statsFromDB.lastStudyDate) : undefined;
|
||||
const dayDifference = getDayDifference(new Date(), lastStudyDate);
|
||||
const isSameDay = dayDifference === 0;
|
||||
|
||||
return {
|
||||
streakDays: dayDifference !== null && dayDifference > 1 ? 0 : statsFromDB.streakDays || 0,
|
||||
todayStudied: isSameDay ? statsFromDB.todayStudied || 0 : 0,
|
||||
todayNewWords: isSameDay ? statsFromDB.todayNewWords || 0 : 0,
|
||||
longestStreak: statsFromDB.longestStreak || 0,
|
||||
lastStudyDate,
|
||||
weeklyGoal: statsFromDB.weeklyGoal || DAILY_GOAL * 7,
|
||||
weeklyProgress: isSameDay ? statsFromDB.weeklyProgress || statsFromDB.todayStudied || 0 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const buildStats = (
|
||||
words: Word[],
|
||||
progress: Map<string, StudyProgress>,
|
||||
overrides: Partial<StudyStats> = {},
|
||||
): StudyStats => {
|
||||
const masteredWords = [...progress.values()].filter((entry) => getMastery(entry) >= MASTERED_THRESHOLD).length;
|
||||
|
||||
return {
|
||||
...emptyStats,
|
||||
totalWords: words.length,
|
||||
masteredWords,
|
||||
studyingWords: Math.max(words.length - masteredWords, 0),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesFilters = (word: Word, selectedCategory: string | 'all', searchQuery: string) => {
|
||||
if (selectedCategory !== 'all' && word.category !== selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!searchQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedQuery = searchQuery.toLowerCase();
|
||||
return (
|
||||
word.french.toLowerCase().includes(normalizedQuery) ||
|
||||
word.english.toLowerCase().includes(normalizedQuery) ||
|
||||
word.category.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
};
|
||||
|
||||
const getDueWordsFor = (
|
||||
words: Word[],
|
||||
progress: Map<string, StudyProgress>,
|
||||
selectedCategory: string | 'all' = 'all',
|
||||
searchQuery = '',
|
||||
) => {
|
||||
const now = new Date();
|
||||
|
||||
return words
|
||||
.filter((word) => matchesFilters(word, selectedCategory, searchQuery))
|
||||
.filter((word) => {
|
||||
const entry = progress.get(word.id);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Date(entry.nextReviewDate) <= now;
|
||||
})
|
||||
.sort((first, second) => {
|
||||
const nextFirst = progress.get(first.id)?.nextReviewDate;
|
||||
const nextSecond = progress.get(second.id)?.nextReviewDate;
|
||||
return new Date(nextFirst || 0).getTime() - new Date(nextSecond || 0).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
const getFirstDueWordIndex = (
|
||||
words: Word[],
|
||||
progress: Map<string, StudyProgress>,
|
||||
selectedCategory: string | 'all' = 'all',
|
||||
searchQuery = '',
|
||||
) => {
|
||||
const nextWord = getDueWordsFor(words, progress, selectedCategory, searchQuery)[0];
|
||||
return nextWord ? words.findIndex((word) => word.id === nextWord.id) : -1;
|
||||
};
|
||||
|
||||
const normalizeImportedWords = (existingWords: Word[], newWords: Word[]) => {
|
||||
const seen = new Set(
|
||||
existingWords.map((word) => `${word.french.trim().toLowerCase()}::${word.english.trim().toLowerCase()}`),
|
||||
);
|
||||
|
||||
return newWords
|
||||
.map((word, index) => ({
|
||||
...word,
|
||||
id: word.id || `imported-${Date.now()}-${index}`,
|
||||
category: word.category || 'General',
|
||||
difficulty: word.difficulty || 'beginner',
|
||||
ttsText: word.ttsText || word.french,
|
||||
tags: word.tags || [],
|
||||
}))
|
||||
.filter((word) => word.french?.trim() && word.english?.trim())
|
||||
.filter((word) => {
|
||||
const key = `${word.french.trim().toLowerCase()}::${word.english.trim().toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const playAutoAudio = (word: Word | null, isFlipped: boolean, tts: TtsSettings, speak: (text: string) => void) => {
|
||||
if (!word) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFlipped && tts.autoSpeakExample && word.example) {
|
||||
window.setTimeout(() => speak(word.example || ''), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFlipped && tts.autoSpeakWord) {
|
||||
window.setTimeout(() => speak(word.ttsText || word.french), 0);
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
words: [],
|
||||
progress: new Map(),
|
||||
stats: emptyStats,
|
||||
currentWordIndex: -1,
|
||||
isFlipped: false,
|
||||
isLoading: true,
|
||||
selectedCategory: 'all',
|
||||
@@ -72,100 +224,111 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
const wordsFromDB = await db.words.toArray();
|
||||
const progressFromDB = await db.progress.toArray();
|
||||
const progressMap = new Map<string, StudyProgress>();
|
||||
progressFromDB.forEach((entry) => progressMap.set(entry.wordId, withMastery(entry)));
|
||||
|
||||
progressFromDB.forEach(p => {
|
||||
const mastery = calculateMastery(p);
|
||||
progressMap.set(p.wordId, { ...p, mastery });
|
||||
});
|
||||
|
||||
const statsFromDB = await db.stats.get('main');
|
||||
const today = new Date().toDateString();
|
||||
const lastStudyDate = statsFromDB?.lastStudyDate
|
||||
? new Date(statsFromDB.lastStudyDate).toDateString()
|
||||
: null;
|
||||
|
||||
let streakDays = statsFromDB?.streakDays || 0;
|
||||
if (statsFromDB?.lastStudyDate && lastStudyDate) {
|
||||
const lastDate = new Date(statsFromDB.lastStudyDate);
|
||||
const diffDays = Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > 1) {
|
||||
streakDays = 0; // 断签了
|
||||
} else if (diffDays === 1 && today !== lastStudyDate) {
|
||||
streakDays += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是新的一天,重置今日计数
|
||||
const todayStudied = today === lastStudyDate ? (statsFromDB?.todayStudied || 0) : 0;
|
||||
const statsFromDB = (await db.stats.get('main')) as PersistedStats | undefined;
|
||||
const nextStats = buildStats(wordsFromDB, progressMap, getPersistedStatsOverrides(statsFromDB));
|
||||
|
||||
set({
|
||||
words: wordsFromDB,
|
||||
progress: progressMap,
|
||||
stats: {
|
||||
totalWords: wordsFromDB.length,
|
||||
masteredWords: progressMap.size > 0 ?
|
||||
Array.from(progressMap.values()).filter(p => (p.mastery || 0) >= 80).length : 0,
|
||||
studyingWords: wordsFromDB.length,
|
||||
streakDays,
|
||||
todayStudied,
|
||||
todayNewWords: 0,
|
||||
longestStreak: statsFromDB?.longestStreak || 0,
|
||||
lastStudyDate: new Date(),
|
||||
weeklyGoal: DAILY_GOAL * 7,
|
||||
weeklyProgress: todayStudied,
|
||||
},
|
||||
stats: nextStats,
|
||||
currentWordIndex: getFirstDueWordIndex(wordsFromDB, progressMap),
|
||||
isFlipped: false,
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
|
||||
setWords: (words) => set({
|
||||
setWords: (words) =>
|
||||
set((state) => ({
|
||||
words,
|
||||
stats: { ...get().stats, totalWords: words.length }
|
||||
stats: buildStats(words, state.progress, {
|
||||
...state.stats,
|
||||
weeklyGoal: state.stats.weeklyGoal || DAILY_GOAL * 7,
|
||||
}),
|
||||
currentWordIndex: getFirstDueWordIndex(words, state.progress, state.selectedCategory, state.searchQuery),
|
||||
isFlipped: false,
|
||||
})),
|
||||
|
||||
addWords: async (newWords) => {
|
||||
const wordsWithDate = newWords.map(w => ({
|
||||
...w,
|
||||
addedAt: new Date(),
|
||||
lastModified: new Date()
|
||||
const state = get();
|
||||
const normalized = normalizeImportedWords(state.words, newWords);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const wordsWithDates = normalized.map((word) => ({
|
||||
...word,
|
||||
addedAt: word.addedAt || now,
|
||||
lastModified: now,
|
||||
}));
|
||||
await db.words.bulkAdd(wordsWithDate);
|
||||
|
||||
await db.words.bulkAdd(wordsWithDates);
|
||||
|
||||
const allWords = await db.words.toArray();
|
||||
const nextStats = buildStats(allWords, state.progress, {
|
||||
...state.stats,
|
||||
todayNewWords: state.stats.todayNewWords + normalized.length,
|
||||
weeklyGoal: state.stats.weeklyGoal || DAILY_GOAL * 7,
|
||||
});
|
||||
|
||||
await db.stats.put({ ...nextStats, id: 'main' });
|
||||
|
||||
set({
|
||||
words: allWords,
|
||||
stats: {
|
||||
...get().stats,
|
||||
totalWords: allWords.length,
|
||||
todayNewWords: get().stats.todayNewWords + newWords.length
|
||||
}
|
||||
stats: nextStats,
|
||||
currentWordIndex: getFirstDueWordIndex(allWords, state.progress, state.selectedCategory, state.searchQuery),
|
||||
isFlipped: false,
|
||||
});
|
||||
},
|
||||
|
||||
deleteWord: async (id) => {
|
||||
const state = get();
|
||||
|
||||
await db.words.delete(id);
|
||||
await db.progress.delete(id);
|
||||
|
||||
const newWords = get().words.filter(w => w.id !== id);
|
||||
const newProgress = new Map(get().progress);
|
||||
newProgress.delete(id);
|
||||
const nextWords = state.words.filter((word) => word.id !== id);
|
||||
const nextProgress = new Map(state.progress);
|
||||
nextProgress.delete(id);
|
||||
|
||||
const nextStats = buildStats(nextWords, nextProgress, {
|
||||
...state.stats,
|
||||
weeklyGoal: state.stats.weeklyGoal || DAILY_GOAL * 7,
|
||||
});
|
||||
|
||||
await db.stats.put({ ...nextStats, id: 'main' });
|
||||
|
||||
set({
|
||||
words: newWords,
|
||||
progress: newProgress,
|
||||
stats: { ...get().stats, totalWords: newWords.length }
|
||||
words: nextWords,
|
||||
progress: nextProgress,
|
||||
stats: nextStats,
|
||||
currentWordIndex: getFirstDueWordIndex(nextWords, nextProgress, state.selectedCategory, state.searchQuery),
|
||||
isFlipped: false,
|
||||
});
|
||||
},
|
||||
|
||||
flipCard: () => set((state) => ({ isFlipped: !state.isFlipped })),
|
||||
flipCard: () => {
|
||||
const state = get();
|
||||
const nextIsFlipped = !state.isFlipped;
|
||||
const currentWord = state.getCurrentWord();
|
||||
|
||||
set({ isFlipped: nextIsFlipped });
|
||||
playAutoAudio(currentWord, nextIsFlipped, state.tts, get().speak);
|
||||
},
|
||||
|
||||
rateWord: async (rating) => {
|
||||
const state = get();
|
||||
const currentWord = state.getCurrentWord();
|
||||
if (!currentWord) return;
|
||||
if (!currentWord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingProgress = state.progress.get(currentWord.id);
|
||||
const newProgress = calculateNextReview(
|
||||
const reviewedProgress = withMastery(
|
||||
calculateNextReview(
|
||||
existingProgress || {
|
||||
wordId: currentWord.id,
|
||||
interval: 0,
|
||||
@@ -174,160 +337,117 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
nextReviewDate: new Date(),
|
||||
lastStudiedDate: new Date(),
|
||||
},
|
||||
rating
|
||||
rating,
|
||||
),
|
||||
);
|
||||
|
||||
const mastery = calculateMastery(newProgress);
|
||||
newProgress.mastery = mastery;
|
||||
await db.progress.put(reviewedProgress);
|
||||
|
||||
await db.progress.put(newProgress);
|
||||
const nextProgress = new Map(state.progress);
|
||||
nextProgress.set(currentWord.id, reviewedProgress);
|
||||
|
||||
const newProgressMap = new Map(state.progress);
|
||||
newProgressMap.set(currentWord.id, newProgress);
|
||||
|
||||
const today = new Date().toDateString();
|
||||
const lastStudyDate = state.stats.lastStudyDate
|
||||
? new Date(state.stats.lastStudyDate).toDateString()
|
||||
: null;
|
||||
const now = new Date();
|
||||
const lastStudyDate = state.stats.lastStudyDate ? new Date(state.stats.lastStudyDate) : undefined;
|
||||
const dayDifference = getDayDifference(now, lastStudyDate);
|
||||
|
||||
let streakDays = state.stats.streakDays;
|
||||
if (today !== lastStudyDate) {
|
||||
if (dayDifference === null || dayDifference > 1) {
|
||||
streakDays = 1;
|
||||
} else if (dayDifference === 1) {
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
const newStats = {
|
||||
const nextStats = buildStats(state.words, nextProgress, {
|
||||
...state.stats,
|
||||
todayStudied: state.stats.todayStudied + 1,
|
||||
weeklyProgress: (state.stats.weeklyProgress || 0) + 1,
|
||||
streakDays,
|
||||
longestStreak: Math.max(streakDays, state.stats.longestStreak || 0),
|
||||
lastStudyDate: new Date(),
|
||||
masteredWords: Array.from(newProgressMap.values()).filter(p => (p.mastery || 0) >= 80).length,
|
||||
};
|
||||
|
||||
await db.stats.put({ ...newStats, id: 'main' });
|
||||
|
||||
set({
|
||||
progress: newProgressMap,
|
||||
isFlipped: false,
|
||||
stats: newStats,
|
||||
todayStudied: dayDifference === 0 ? state.stats.todayStudied + 1 : 1,
|
||||
weeklyProgress: dayDifference === 0 ? (state.stats.weeklyProgress || 0) + 1 : 1,
|
||||
weeklyGoal: state.stats.weeklyGoal || DAILY_GOAL * 7,
|
||||
lastStudyDate: now,
|
||||
});
|
||||
|
||||
setTimeout(() => get().nextWord(), 300);
|
||||
await db.stats.put({ ...nextStats, id: 'main' });
|
||||
|
||||
set({
|
||||
progress: nextProgress,
|
||||
isFlipped: false,
|
||||
stats: nextStats,
|
||||
});
|
||||
|
||||
window.setTimeout(() => get().nextWord(), 300);
|
||||
},
|
||||
|
||||
nextWord: () => {
|
||||
const state = get();
|
||||
const dueWords = state.getDueWords();
|
||||
const nextIndex = getFirstDueWordIndex(state.words, state.progress, state.selectedCategory, state.searchQuery);
|
||||
const nextWord = nextIndex >= 0 ? state.words[nextIndex] : null;
|
||||
|
||||
if (dueWords.length === 0) {
|
||||
set({ currentWordIndex: -1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = state.words.findIndex(w => w.id === dueWords[0].id);
|
||||
set({
|
||||
currentWordIndex: nextIndex,
|
||||
isFlipped: false
|
||||
isFlipped: false,
|
||||
});
|
||||
|
||||
playAutoAudio(nextWord, false, state.tts, get().speak);
|
||||
},
|
||||
|
||||
getCurrentWord: () => {
|
||||
const state = get();
|
||||
if (state.currentWordIndex < 0 || state.currentWordIndex >= state.words.length) {
|
||||
return null;
|
||||
const directWord = state.words[state.currentWordIndex];
|
||||
|
||||
if (!directWord || !matchesFilters(directWord, state.selectedCategory, state.searchQuery)) {
|
||||
const nextIndex = getFirstDueWordIndex(state.words, state.progress, state.selectedCategory, state.searchQuery);
|
||||
return nextIndex >= 0 ? state.words[nextIndex] : null;
|
||||
}
|
||||
return state.words[state.currentWordIndex];
|
||||
|
||||
const progress = state.progress.get(directWord.id);
|
||||
if (progress && new Date(progress.nextReviewDate) > new Date()) {
|
||||
const nextIndex = getFirstDueWordIndex(state.words, state.progress, state.selectedCategory, state.searchQuery);
|
||||
return nextIndex >= 0 ? state.words[nextIndex] : null;
|
||||
}
|
||||
|
||||
return directWord;
|
||||
},
|
||||
|
||||
getDueWords: () => {
|
||||
const state = get();
|
||||
const now = new Date();
|
||||
const { selectedCategory, searchQuery } = state;
|
||||
|
||||
let dueWords = state.words.filter((word) => {
|
||||
const progress = state.progress.get(word.id);
|
||||
if (!progress) return true;
|
||||
return new Date(progress.nextReviewDate) <= now;
|
||||
});
|
||||
|
||||
// 按分类过滤
|
||||
if (selectedCategory !== 'all') {
|
||||
dueWords = dueWords.filter(w => w.category === selectedCategory);
|
||||
}
|
||||
|
||||
// 按搜索过滤
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
dueWords = dueWords.filter(w =>
|
||||
w.french.toLowerCase().includes(query) ||
|
||||
w.english.toLowerCase().includes(query) ||
|
||||
w.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return dueWords;
|
||||
return getDueWordsFor(state.words, state.progress, state.selectedCategory, state.searchQuery);
|
||||
},
|
||||
|
||||
getFilteredWords: () => {
|
||||
const state = get();
|
||||
let filtered = state.words;
|
||||
|
||||
if (state.selectedCategory !== 'all') {
|
||||
filtered = filtered.filter(w => w.category === state.selectedCategory);
|
||||
}
|
||||
|
||||
if (state.searchQuery) {
|
||||
const query = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(w =>
|
||||
w.french.toLowerCase().includes(query) ||
|
||||
w.english.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
return state.words.filter((word) => matchesFilters(word, state.selectedCategory, state.searchQuery));
|
||||
},
|
||||
|
||||
exportData: async () => {
|
||||
return await exportData();
|
||||
},
|
||||
exportData: async () => exportBackupData(),
|
||||
|
||||
importData: async (data) => {
|
||||
await importData(data);
|
||||
await importBackupData(data);
|
||||
await get().init();
|
||||
},
|
||||
|
||||
resetProgress: async () => {
|
||||
await db.progress.clear();
|
||||
set({ progress: new Map(), isFlipped: false, currentWordIndex: 0 });
|
||||
},
|
||||
|
||||
setCategory: (category) => set({ selectedCategory: category }),
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
speak: (text: string) => {
|
||||
if (!text?.trim()) return;
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
if (!text?.trim() || !('speechSynthesis' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const synth = window.speechSynthesis;
|
||||
const { tts } = get();
|
||||
|
||||
const pickFrenchVoice = () => {
|
||||
const voices = synth.getVoices();
|
||||
if (!voices || voices.length === 0) return null;
|
||||
|
||||
// If user selected a voice, prefer it.
|
||||
if (tts.voiceURI) {
|
||||
const chosen = voices.find(v => v.voiceURI === tts.voiceURI);
|
||||
if (chosen) return chosen;
|
||||
if (!voices.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
voices.find(v => v.lang?.toLowerCase() === 'fr-fr') ||
|
||||
voices.find(v => v.lang?.toLowerCase().startsWith('fr')) ||
|
||||
null
|
||||
);
|
||||
if (tts.voiceURI) {
|
||||
const selectedVoice = voices.find((voice) => voice.voiceURI === tts.voiceURI);
|
||||
if (selectedVoice) {
|
||||
return selectedVoice;
|
||||
}
|
||||
}
|
||||
|
||||
return voices.find((voice) => voice.lang?.toLowerCase() === 'fr-fr') || voices.find((voice) => voice.lang?.toLowerCase().startsWith('fr')) || null;
|
||||
};
|
||||
|
||||
const speakNow = () => {
|
||||
@@ -337,22 +457,25 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
utterance.pitch = tts.pitch;
|
||||
|
||||
const voice = pickFrenchVoice();
|
||||
if (voice) utterance.voice = voice;
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
|
||||
synth.cancel();
|
||||
synth.speak(utterance);
|
||||
};
|
||||
|
||||
if (synth.getVoices().length === 0) {
|
||||
const onVoicesChanged = () => {
|
||||
synth.removeEventListener('voiceschanged', onVoicesChanged);
|
||||
const handleVoicesChanged = () => {
|
||||
synth.removeEventListener('voiceschanged', handleVoicesChanged);
|
||||
speakNow();
|
||||
};
|
||||
synth.addEventListener('voiceschanged', onVoicesChanged);
|
||||
|
||||
synth.addEventListener('voiceschanged', handleVoicesChanged);
|
||||
synth.getVoices();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
synth.removeEventListener('voiceschanged', onVoicesChanged);
|
||||
synth.removeEventListener('voiceschanged', handleVoicesChanged);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -364,50 +487,100 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
speakNow();
|
||||
},
|
||||
|
||||
loadSettings: async () => {
|
||||
const ttsFromDb = await db.settings.get('tts');
|
||||
if (ttsFromDb?.value) {
|
||||
set({ tts: { ...DEFAULT_TTS_SETTINGS, ...ttsFromDb.value } });
|
||||
}
|
||||
},
|
||||
setCategory: (category) =>
|
||||
set((state) => ({
|
||||
selectedCategory: category,
|
||||
currentWordIndex: getFirstDueWordIndex(state.words, state.progress, category, state.searchQuery),
|
||||
isFlipped: false,
|
||||
})),
|
||||
|
||||
setTtsSettings: async (patch) => {
|
||||
const next = { ...get().tts, ...patch };
|
||||
set({ tts: next });
|
||||
await db.settings.put({ key: 'tts', value: next });
|
||||
},
|
||||
setSearchQuery: (query) =>
|
||||
set((state) => ({
|
||||
searchQuery: query,
|
||||
currentWordIndex: getFirstDueWordIndex(state.words, state.progress, state.selectedCategory, query),
|
||||
isFlipped: false,
|
||||
})),
|
||||
|
||||
resetProgress: async () => {
|
||||
const state = get();
|
||||
await db.progress.clear();
|
||||
|
||||
const nextProgress = new Map<string, StudyProgress>();
|
||||
const nextStats = buildStats(state.words, nextProgress, {
|
||||
...state.stats,
|
||||
streakDays: 0,
|
||||
todayStudied: 0,
|
||||
todayNewWords: state.stats.todayNewWords,
|
||||
longestStreak: state.stats.longestStreak || 0,
|
||||
lastStudyDate: undefined,
|
||||
weeklyGoal: state.stats.weeklyGoal || DAILY_GOAL * 7,
|
||||
weeklyProgress: 0,
|
||||
});
|
||||
|
||||
await db.stats.put({ ...nextStats, id: 'main' });
|
||||
|
||||
refreshVoices: () => {
|
||||
if (!('speechSynthesis' in window)) return;
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
set({
|
||||
availableVoices: voices.map(v => ({ name: v.name, lang: v.lang, voiceURI: v.voiceURI }))
|
||||
progress: nextProgress,
|
||||
stats: nextStats,
|
||||
isFlipped: false,
|
||||
currentWordIndex: getFirstDueWordIndex(state.words, nextProgress, state.selectedCategory, state.searchQuery),
|
||||
});
|
||||
},
|
||||
|
||||
getCategoryStats: () => {
|
||||
const state = get();
|
||||
const categories = [...new Set(state.words.map(w => w.category))];
|
||||
const categories = [...new Set(state.words.map((word) => word.category))];
|
||||
|
||||
return categories.map(category => {
|
||||
const categoryWords = state.words.filter(w => w.category === category);
|
||||
const masteredCount = categoryWords.filter(w => {
|
||||
const progress = state.progress.get(w.id);
|
||||
return progress && (progress.mastery || 0) >= 80;
|
||||
return categories.map((category) => {
|
||||
const categoryWords = state.words.filter((word) => word.category === category);
|
||||
const mastered = categoryWords.filter((word) => {
|
||||
const progress = state.progress.get(word.id);
|
||||
return progress ? getMastery(progress) >= MASTERED_THRESHOLD : false;
|
||||
}).length;
|
||||
|
||||
const dueCount = categoryWords.filter(w => {
|
||||
const progress = state.progress.get(w.id);
|
||||
if (!progress) return true;
|
||||
const due = categoryWords.filter((word) => {
|
||||
const progress = state.progress.get(word.id);
|
||||
if (!progress) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Date(progress.nextReviewDate) <= new Date();
|
||||
}).length;
|
||||
|
||||
return {
|
||||
category,
|
||||
total: categoryWords.length,
|
||||
mastered: masteredCount,
|
||||
due: dueCount,
|
||||
mastered,
|
||||
due,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
loadSettings: async () => {
|
||||
const settingsFromDb = await db.settings.get('tts');
|
||||
if (settingsFromDb?.value) {
|
||||
set({ tts: { ...DEFAULT_TTS_SETTINGS, ...settingsFromDb.value } });
|
||||
}
|
||||
},
|
||||
|
||||
setTtsSettings: async (patch) => {
|
||||
const nextSettings = { ...get().tts, ...patch };
|
||||
set({ tts: nextSettings });
|
||||
await db.settings.put({ key: 'tts', value: nextSettings });
|
||||
},
|
||||
|
||||
refreshVoices: () => {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
set({
|
||||
availableVoices: voices.map((voice) => ({
|
||||
name: voice.name,
|
||||
lang: voice.lang,
|
||||
voiceURI: voice.voiceURI,
|
||||
})),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user