feat: stabilize study flow and import experience

This commit is contained in:
Codex
2026-03-18 15:45:39 +08:00
parent 4ea96243c2
commit 7775541cdb
6 changed files with 821 additions and 574 deletions

View File

@@ -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,159 +83,182 @@ 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 */}
<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>
<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-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="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" />
</svg>
</button>
<button
onClick={() => setShowImport(true)}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
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" />
</svg>
</button>
<button
onClick={handleExport}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
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" />
</svg>
</button>
</div>
</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="发音设置"
>
<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" />
</svg>
</button>
<button
onClick={() => setShowImport(true)}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
title="导入词库"
>
<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" />
</svg>
</button>
<button
onClick={handleExport}
className="p-2 bg-white/80 rounded-full shadow-sm hover:bg-white transition-colors"
title="导出备份"
>
<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" />
</svg>
</button>
<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>
<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">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 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">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>
<h1 className="text-4xl font-bold text-gray-800 mb-2">Master French</h1>
<p className="text-gray-500">Learn with spaced repetition</p>
</header>
{/* Stats */}
<div className="grid grid-cols-3 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="mb-8">
<ProgressBar current={stats.todayStudied} total={20} label="Daily goal" />
</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="mb-6">
<FlashCard word={currentWord} isFlipped={isFlipped} onFlip={flipCard} onSpeak={speak} />
</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>
</div>
{/* Progress */}
<div className="mb-8">
<ProgressBar
current={stats.todayStudied}
total={20}
label="Daily Goal"
/>
</div>
{!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 the card to reveal the translation
</span>
</div>
)}
{/* Flash Card */}
<div className="mb-6">
<FlashCard
word={currentWord}
onFlip={flipCard}
onSpeak={speak}
/>
</div>
{isFlipped && <RatingButtons onRate={rateWord} />}
{/* 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">
<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="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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Click card to reveal answer
</span>
</div>
)}
Powered by the SM-2 review algorithm with local persistence
</div>
</footer>
</div>
{/* Rating Buttons */}
{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
<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>
</footer>
<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={addWords}
/>
<ImportModal isOpen={showImport} onClose={() => setShowImport(false)} onImport={handleImport} />
<TtsSettingsModal
isOpen={showTtsSettings}
onClose={() => setShowTtsSettings(false)}
/>
<TtsSettingsModal isOpen={showTtsSettings} onClose={() => setShowTtsSettings(false)} />
</div>
);
}

View File

@@ -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">&quot;{word.example}&quot;</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" />

View File

@@ -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,134 +37,172 @@ 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];
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 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(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
throw jsonError instanceof Error ? jsonError : new Error('There is no importable content.');
}
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 words = dataLines
.map((line, index) => {
const parts = parseCsvLine(line);
if (hasHeader) {
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(
{
french: parts[0] || '',
english: parts[1] || '',
pronunciation: parts[2] || '',
category: parts[3] || 'General',
difficulty: (parts[4] as Word['difficulty']) || 'beginner',
chinese: parts[5] || '',
example: parts[6] || '',
exampleTranslation: parts[7] || '',
tags: parseTags(parts[8]),
},
index,
);
})
.filter(Boolean) as Word[];
if (words.length === 0) {
throw jsonError instanceof Error ? jsonError : new Error('Unable to parse the provided content.');
}
await finishImport(words);
}
};
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
reader.onload = async (loadEvent) => {
const text = String(loadEvent.target?.result || '');
setContent(text);
parseContent(text);
await parseContent(text);
};
reader.readAsText(file);
};
const parseContent = (text: string) => {
setError('');
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 lines = text
.trim()
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (lines.length === 0) {
setError('没有可导入的内容');
return;
}
const firstLine = parseCsvLine(lines[0]).map(v => v.toLowerCase());
const hasHeader = firstLine.includes('french') || firstLine.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[0] || '',
english: parts[1] || '',
pronunciation: parts[2] || '',
category: parts[3] || 'General',
example: parts[4] || '',
exampleTranslation: parts[5] || '',
},
index
);
})
.filter(Boolean) as Word[];
if (words.length > 0) {
onImport(words);
onClose();
} else {
setError('无法解析文件格式,请检查 french 和 english 字段是否存在');
}
};
const handleSubmit = () => {
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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,35 +29,186 @@ 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;
const emptyStats: StudyStats = {
totalWords: 0,
masteredWords: 0,
studyingWords: 0,
streakDays: 0,
todayStudied: 0,
todayNewWords: 0,
weeklyGoal: DAILY_GOAL * 7,
weeklyProgress: 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: {
totalWords: 0,
masteredWords: 0,
studyingWords: 0,
streakDays: 0,
todayStudied: 0,
todayNewWords: 0,
weeklyGoal: DAILY_GOAL * 7,
weeklyProgress: 0,
},
currentWordIndex: 0,
stats: emptyStats,
currentWordIndex: -1,
isFlipped: false,
isLoading: true,
selectedCategory: 'all',
@@ -72,262 +224,230 @@ 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({
words,
stats: { ...get().stats, totalWords: words.length }
}),
setWords: (words) =>
set((state) => ({
words,
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(
existingProgress || {
wordId: currentWord.id,
interval: 0,
repetitions: 0,
easeFactor: 2.5,
nextReviewDate: new Date(),
lastStudiedDate: new Date(),
},
rating
const reviewedProgress = withMastery(
calculateNextReview(
existingProgress || {
wordId: currentWord.id,
interval: 0,
repetitions: 0,
easeFactor: 2.5,
nextReviewDate: new Date(),
lastStudiedDate: new Date(),
},
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,
})),
});
},
}));