Compare commits

...

2 Commits

Author SHA1 Message Date
Codex
7775541cdb feat: stabilize study flow and import experience 2026-03-18 15:45:39 +08:00
likingcode
4ea96243c2 Update: TTS settings modal and settings types 2026-03-18 15:18:47 +08:00
13 changed files with 1877 additions and 386 deletions

View File

@@ -1,22 +1,28 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#3b82f6" />
<meta name="description" content="使用间隔重复系统高效学习法语单词" />
<meta name="keywords" content="法语学习单词记忆SRS" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="法语词汇" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="manifest" href="/manifest.json" />
<title>French Vocabulary</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<title>法语词汇学习</title>
</head>
<body>
<div id="root"></div>
<noscript>
<div style="padding: 20px; text-align: center;">
<h2>需要启用 JavaScript</h2>
<p>请启用 JavaScript 以使用法语词汇学习应用</p>
</div>
</noscript>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(console.error);
});
}
</script>
</body>
</html>

View File

@@ -1,21 +1,44 @@
{
"name": "French Vocabulary",
"short_name": "FrenchVocab",
"description": "Master French with spaced repetition",
"name": "法语词汇学习",
"short_name": "法语词汇",
"description": "使用间隔重复系统高效学习法语单词",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color":="#ffffff",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"lang": "zh-CN",
"dir": "ltr",
"categories": ["education", "productivity"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "开始学习",
"short_name": "学习",
"description": "开始今日学习",
"url": "/?action=study",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "导入词库",
"short_name": "导入",
"description": "导入新的单词",
"url": "/?action=import",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
]
}

View File

@@ -1,13 +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);
// Export functionality handled by direct download
const [showTtsSettings, setShowTtsSettings] = useState(false);
const {
init,
getCurrentWord,
@@ -17,6 +18,7 @@ function App() {
stats,
getDueWords,
addWords,
importData,
exportData,
speak,
isLoading,
@@ -28,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>
);
@@ -60,58 +83,64 @@ 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="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="导入词库"
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" />
@@ -120,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" />
@@ -128,77 +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>
<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>
);
}

View File

@@ -1,65 +1,75 @@
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 handleSpeak = (e: React.MouseEvent) => {
e.stopPropagation();
onSpeak?.(word.french);
const handleSpeakWord = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onSpeak?.(word.ttsText || word.french);
};
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 h-96 cursor-pointer group"
style={{ perspective: '1000px' }}
onClick={handleFlip}
>
<div
className="relative w-full h-full transition-all duration-500 ease-out"
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">
<span className="bg-white/20 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium">
{word.category}
<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>
{word.difficulty && (
<span className="bg-white/15 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium capitalize">
{word.difficulty}
</span>
)}
</div>
{/* Speak Button */}
<button
onClick={handleSpeak}
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" />
</svg>
</button>
<div className="text-center">
<h2 className="text-6xl font-bold mb-4 drop-shadow-lg">{word.french}</h2>
{word.pronunciation && (
<span className="text-xl opacity-90 font-light tracking-wide">/{word.pronunciation}/</span>
<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.tags && word.tags.length > 0 && (
<div className="mt-4 flex flex-wrap justify-center gap-2">
{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>
))}
</div>
)}
</div>
@@ -67,29 +77,54 @@ 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="text-center space-y-4">
<h3 className="text-4xl font-bold drop-shadow-lg">{word.english}</h3>
<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>}
</div>
{word.example && (
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-2xl p-6 max-w-md">
<p className="text-lg italic mb-3 leading-relaxed">&quot;{word.example}&quot;</p>
<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">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>}
</div>
<button
onClick={handleSpeakExample}
className="shrink-0 p-2 bg-white/15 backdrop-blur-sm rounded-full hover:bg-white/25 transition-colors"
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" />
</svg>
</button>
</div>
{word.exampleTranslation && (
<p className="text-sm opacity-80 border-t border-white/20 pt-3">{word.exampleTranslation}</p>
<p className="text-sm opacity-90 border-t border-white/20 pt-3">{word.exampleTranslation}</p>
)}
</div>
)}
{word.notes && (
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-4 text-left">
<div className="text-xs uppercase tracking-[0.25em] text-white/70 mb-2">Notes</div>
<p className="text-sm text-white/90 leading-relaxed">{word.notes}</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,89 +1,208 @@
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: any[]) => void;
onImport: (payload: ImportPayload) => Promise<void> | void;
}
const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
const french = input.french?.trim();
const english = input.english?.trim();
if (!french || !english) {
return null;
}
return {
id: input.id || `imported-${Date.now()}-${index}`,
french,
english,
pronunciation: input.pronunciation?.trim() || '',
ttsText: input.ttsText?.trim() || french,
chinese: input.chinese?.trim() || '',
partOfSpeech: input.partOfSpeech?.trim() || '',
example: input.example?.trim() || '',
exampleTranslation: input.exampleTranslation?.trim() || '',
examplePronunciation: input.examplePronunciation?.trim() || '',
notes: input.notes?.trim() || '',
audioUrl: input.audioUrl?.trim() || '',
category: input.category?.trim() || 'General',
difficulty: input.difficulty || 'beginner',
tags: input.tags || [],
};
};
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 finishImport = async (payload: ImportPayload) => {
setError('');
setIsImporting(true);
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) => {
try {
// Try JSON first
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)) {
onImport(json);
onClose();
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.words && Array.isArray(json.words)) {
onImport(json.words);
onClose();
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;
}
} catch {
// Not JSON, try CSV
const lines = text.trim().split('\n');
const words = lines.map((line, index) => {
const parts = line.split(',').map(p => p.trim());
return {
id: `imported-${Date.now()}-${index}`,
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: 'beginner',
};
}).filter(w => w.french && w.english);
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) {
onImport(words);
onClose();
} else {
setError('无法解析文件格式');
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-lg w-full p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4"></h2>
<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">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}
@@ -93,41 +212,43 @@ 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,pronunciation,category
Bonjour,Hello,bɔ̃ʒuʁ,Greetings
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"}]`}
className="w-full h-40 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
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"
/>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 pt-2">
<button
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

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { DifficultyRating } from '../types/vocabulary';
interface RatingButtonsProps {
@@ -5,50 +6,93 @@ interface RatingButtonsProps {
disabled?: boolean;
}
export function RatingButtons({ onRate, disabled }: RatingButtonsProps) {
const buttons = [
interface ButtonConfig {
rating: DifficultyRating;
label: string;
sublabel: string;
shortcut: string;
color: string;
shadow: string;
}
const BUTTONS: ButtonConfig[] = [
{
rating: 'again' as DifficultyRating,
label: '重来',
sublabel: '< 1 min',
rating: 'again',
label: 'Again',
sublabel: '< 10 min',
shortcut: '1',
color: 'from-rose-400 to-red-500',
shadow: 'shadow-rose-200'
shadow: 'shadow-rose-200',
},
{
rating: 'hard' as DifficultyRating,
label: '困难',
sublabel: '~ 2 days',
rating: 'hard',
label: 'Hard',
sublabel: '~ 1 day',
shortcut: '2',
color: 'from-orange-400 to-amber-500',
shadow: 'shadow-orange-200'
shadow: 'shadow-orange-200',
},
{
rating: 'good' as DifficultyRating,
label: '良好',
sublabel: '~ 4 days',
rating: 'good',
label: 'Good',
sublabel: '~ 3 days',
shortcut: '3',
color: 'from-blue-400 to-indigo-500',
shadow: 'shadow-blue-200'
shadow: 'shadow-blue-200',
},
{
rating: 'easy' as DifficultyRating,
label: '简单',
sublabel: '~ 7 days',
rating: 'easy',
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 = (event: KeyboardEvent) => {
if (disabled) {
return;
}
const keyMap: Record<string, DifficultyRating> = {
'1': 'again',
'2': 'hard',
'3': 'good',
'4': 'easy',
Enter: 'good',
' ': 'good',
};
const rating = keyMap[event.key];
if (!rating) {
return;
}
event.preventDefault();
onRate(rating);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onRate, disabled]);
return (
<div className="grid grid-cols-4 gap-3 mt-8">
{buttons.map(({ rating, label, sublabel, color, shadow }) => (
{BUTTONS.map(({ rating, label, sublabel, shortcut, color, shadow }) => (
<button
key={rating}
onClick={() => onRate(rating)}
disabled={disabled}
className={`group relative overflow-hidden bg-gradient-to-b ${color} ${shadow} shadow-lg hover:shadow-xl text-white rounded-2xl py-4 px-2 transition-all duration-200 hover:-translate-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0`}
aria-label={`${label} (${shortcut})`}
>
<div className="relative z-10 flex flex-col items-center">
<span className="text-lg font-bold">{label}</span>
<span className="text-xs opacity-80 mt-1">{sublabel}</span>
<span className="absolute top-1 right-2 text-xs opacity-50 font-mono">{shortcut}</span>
</div>
<div className="absolute inset-0 bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo } from 'react';
import { useAppStore } from '../stores/appStore';
interface TtsSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function TtsSettingsModal({ isOpen, onClose }: TtsSettingsModalProps) {
const { tts, availableVoices, setTtsSettings, refreshVoices } = useAppStore();
useEffect(() => {
if (!isOpen) {
return;
}
refreshVoices();
}, [isOpen, refreshVoices]);
const frenchVoices = useMemo(
() => availableVoices.filter((voice) => voice.lang?.toLowerCase().startsWith('fr')),
[availableVoices],
);
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">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="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>
<select
value={tts.voiceURI || ''}
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="">Auto-select a French voice</option>
{frenchVoices.length > 0 ? (
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>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<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={(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">Pitch</label>
<input
type="range"
min={0.8}
max={1.2}
step={0.05}
value={tts.pitch}
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>
</div>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={tts.autoSpeakWord}
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={(event) => setTtsSettings({ autoSpeakExample: event.target.checked })}
/>
Auto-play the example
</label>
</div>
<div className="flex gap-3 pt-2">
<button
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={() => {
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>
</div>
</div>
);
}

View File

@@ -32,29 +32,517 @@ export const db = new FrenchVocabDB();
export async function initDatabase() {
const count = await db.words.count();
if (count === 0) {
// 添加示例单词
const now = new Date();
const defaultWords: WordEntry[] = [
{
id: '1',
french: 'Bonjour',
pronunciation: 'bɔ̃ʒuʁ',
ttsText: 'bonjour',
english: 'Hello',
example: 'Bonjour, comment allez-vous?',
exampleTranslation: 'Hello, how are you?',
chinese: '你好;早安',
partOfSpeech: 'interjection',
example: 'Bonjour, comment allez-vous ?',
exampleTranslation: '你好,你最近怎么样?',
category: 'Greetings',
difficulty: 'beginner',
addedAt: new Date(),
tags: ['daily', 'greeting'],
addedAt: now,
},
{
id: '2',
french: 'Merci',
pronunciation: 'mɛʁsi',
ttsText: 'merci',
english: 'Thank you',
chinese: '谢谢',
partOfSpeech: 'interjection',
example: 'Merci beaucoup pour votre aide.',
exampleTranslation: 'Thank you very much for your help.',
exampleTranslation: '非常感谢你的帮助。',
category: 'Greetings',
difficulty: 'beginner',
addedAt: new Date(),
tags: ['daily', 'polite'],
addedAt: now,
},
{
id: '3',
french: 'Sil vous plaît',
pronunciation: 'sil vu plɛ',
ttsText: 'sil vous plaît',
english: 'Please',
chinese: '请',
partOfSpeech: 'expression',
example: 'Un café, sil vous plaît.',
exampleTranslation: '请给我一杯咖啡。',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily', 'polite'],
addedAt: now,
},
{
id: '4',
french: 'Salut',
pronunciation: 'sa.ly',
ttsText: 'salut',
english: 'Hi / bye (informal)',
chinese: '嗨;再见(非正式)',
partOfSpeech: 'interjection',
example: 'Salut ! Ça va ?',
exampleTranslation: '嗨!你好吗?',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '5',
french: 'Au revoir',
pronunciation: 'o ʁə.vwaʁ',
ttsText: 'au revoir',
english: 'Goodbye',
chinese: '再见',
partOfSpeech: 'expression',
example: 'Au revoir, à demain !',
exampleTranslation: '再见,明天见!',
category: 'Greetings',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '6',
french: 'Excusez-moi',
pronunciation: 'ɛk.sky.ze mwa',
ttsText: 'excusez-moi',
english: 'Excuse me',
chinese: '打扰一下;对不起(礼貌)',
partOfSpeech: 'expression',
example: 'Excusez-moi, où est la station de métro ?',
exampleTranslation: '打扰一下,地铁站在哪里?',
category: 'Greetings',
difficulty: 'beginner',
tags: ['polite'],
addedAt: now,
},
{
id: '7',
french: 'Pardon',
pronunciation: 'paʁ.dɔ̃',
ttsText: 'pardon',
english: 'Sorry / pardon',
chinese: '抱歉;劳驾',
partOfSpeech: 'interjection',
example: 'Pardon, je nai pas compris.',
exampleTranslation: '抱歉,我没听懂。',
category: 'Greetings',
difficulty: 'beginner',
tags: ['polite'],
addedAt: now,
},
{
id: '8',
french: 'Oui',
pronunciation: 'wi',
ttsText: 'oui',
english: 'Yes',
chinese: '是;对',
partOfSpeech: 'adverb',
example: 'Oui, bien sûr.',
exampleTranslation: '是的,当然。',
category: 'Basics',
difficulty: 'beginner',
tags: ['essential'],
addedAt: now,
},
{
id: '9',
french: 'Non',
pronunciation: 'nɔ̃',
ttsText: 'non',
english: 'No',
chinese: '不',
partOfSpeech: 'adverb',
example: 'Non, merci.',
exampleTranslation: '不,谢谢。',
category: 'Basics',
difficulty: 'beginner',
tags: ['essential'],
addedAt: now,
},
{
id: '10',
french: 'Pomme',
pronunciation: 'pɔm',
ttsText: 'pomme',
english: 'Apple',
chinese: '苹果',
partOfSpeech: 'noun',
example: 'Je mange une pomme après le déjeuner.',
exampleTranslation: '我午饭后吃一个苹果。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'daily'],
addedAt: now,
},
{
id: '11',
french: 'Eau',
pronunciation: 'o',
ttsText: 'eau',
english: 'Water',
chinese: '水',
partOfSpeech: 'noun',
example: 'Je voudrais un verre deau.',
exampleTranslation: '我想要一杯水。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'essential'],
addedAt: now,
},
{
id: '12',
french: 'Pain',
pronunciation: 'pɛ̃',
ttsText: 'pain',
english: 'Bread',
chinese: '面包',
partOfSpeech: 'noun',
example: 'Je prends du pain avec du fromage.',
exampleTranslation: '我吃面包配奶酪。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '13',
french: 'Fromage',
pronunciation: 'fʁɔ.maʒ',
ttsText: 'fromage',
english: 'Cheese',
chinese: '奶酪',
partOfSpeech: 'noun',
example: 'Ce fromage est délicieux.',
exampleTranslation: '这个奶酪很好吃。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '14',
french: 'Café',
pronunciation: 'ka.fe',
ttsText: 'café',
english: 'Coffee',
chinese: '咖啡',
partOfSpeech: 'noun',
example: 'Je bois un café le matin.',
exampleTranslation: '我早上喝咖啡。',
category: 'Food',
difficulty: 'beginner',
tags: ['food', 'daily'],
addedAt: now,
},
{
id: '15',
french: 'Thé',
pronunciation: 'te',
ttsText: 'thé',
english: 'Tea',
chinese: '茶',
partOfSpeech: 'noun',
example: 'Je préfère le thé sans sucre.',
exampleTranslation: '我更喜欢不加糖的茶。',
category: 'Food',
difficulty: 'beginner',
tags: ['food'],
addedAt: now,
},
{
id: '16',
french: 'Maison',
pronunciation: 'mɛ.zɔ̃',
ttsText: 'maison',
english: 'House',
chinese: '房子;家',
partOfSpeech: 'noun',
example: 'Ma maison est près de la gare.',
exampleTranslation: '我家在火车站附近。',
category: 'Places',
difficulty: 'beginner',
tags: ['home'],
addedAt: now,
},
{
id: '17',
french: 'École',
pronunciation: 'e.kɔl',
ttsText: 'école',
english: 'School',
chinese: '学校',
partOfSpeech: 'noun',
example: 'Les enfants vont à lécole.',
exampleTranslation: '孩子们去上学。',
category: 'Places',
difficulty: 'beginner',
tags: ['place'],
addedAt: now,
},
{
id: '18',
french: 'Gare',
pronunciation: 'ɡaʁ',
ttsText: 'gare',
english: 'Train station',
chinese: '火车站',
partOfSpeech: 'noun',
example: 'La gare est à cinq minutes.',
exampleTranslation: '火车站走五分钟就到。',
category: 'Places',
difficulty: 'beginner',
tags: ['travel'],
addedAt: now,
},
{
id: '19',
french: 'Étudier',
pronunciation: 'e.ty.dje',
ttsText: 'étudier',
english: 'To study',
chinese: '学习',
partOfSpeech: 'verb',
example: 'Jétudie le français tous les soirs.',
exampleTranslation: '我每天晚上学法语。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb', 'daily'],
addedAt: now,
},
{
id: '20',
french: 'Apprendre',
pronunciation: 'a.pʁɑ̃dʁ',
ttsText: 'apprendre',
english: 'To learn',
chinese: '学习;学会',
partOfSpeech: 'verb',
example: 'Japprends de nouveaux mots chaque jour.',
exampleTranslation: '我每天学习新单词。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb', 'study'],
addedAt: now,
},
{
id: '21',
french: 'Comprendre',
pronunciation: 'kɔ̃.pʁɑ̃dʁ',
ttsText: 'comprendre',
english: 'To understand',
chinese: '理解',
partOfSpeech: 'verb',
example: 'Je comprends cette phrase maintenant.',
exampleTranslation: '我现在理解这个句子了。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb', 'study'],
addedAt: now,
},
{
id: '22',
french: 'Parler',
pronunciation: 'paʁ.le',
ttsText: 'parler',
english: 'To speak',
chinese: '说(语言)',
partOfSpeech: 'verb',
example: 'Je parle un peu français.',
exampleTranslation: '我会说一点法语。',
category: 'Study',
difficulty: 'beginner',
tags: ['verb'],
addedAt: now,
},
{
id: '23',
french: 'Écrire',
pronunciation: 'e.kʁiʁ',
ttsText: 'écrire',
english: 'To write',
chinese: '写',
partOfSpeech: 'verb',
example: 'Jécris un message à mon ami.',
exampleTranslation: '我给朋友写一条消息。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb'],
addedAt: now,
},
{
id: '24',
french: 'Lire',
pronunciation: 'liʁ',
ttsText: 'lire',
english: 'To read',
chinese: '读',
partOfSpeech: 'verb',
example: 'Je lis un livre intéressant.',
exampleTranslation: '我在读一本有趣的书。',
category: 'Study',
difficulty: 'intermediate',
tags: ['verb'],
addedAt: now,
},
{
id: '25',
french: 'Heureux',
pronunciation: 'ø.ʁø',
ttsText: 'heureux',
english: 'Happy',
chinese: '开心的;幸福的',
partOfSpeech: 'adjective',
example: 'Je suis heureux de te voir.',
exampleTranslation: '见到你我很开心。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['emotion'],
addedAt: now,
},
{
id: '26',
french: 'Triste',
pronunciation: 'tʁist',
ttsText: 'triste',
english: 'Sad',
chinese: '难过的',
partOfSpeech: 'adjective',
example: 'Il est triste aujourdhui.',
exampleTranslation: '他今天很难过。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['emotion'],
addedAt: now,
},
{
id: '27',
french: 'Fatigué',
pronunciation: 'fa.ti.ɡe',
ttsText: 'fatigué',
english: 'Tired',
chinese: '累的',
partOfSpeech: 'adjective',
example: 'Je suis fatigué après le travail.',
exampleTranslation: '我下班后很累。',
category: 'Emotions',
difficulty: 'beginner',
tags: ['daily'],
addedAt: now,
},
{
id: '28',
french: 'Rendez-vous',
pronunciation: 'ʁɑ̃.de.vu',
ttsText: 'rendez-vous',
english: 'Appointment / meeting',
chinese: '约会;预约;会面',
partOfSpeech: 'noun',
example: 'Jai un rendez-vous chez le médecin à dix heures.',
exampleTranslation: '我十点要去看医生。',
category: 'Daily Life',
difficulty: 'intermediate',
tags: ['schedule'],
addedAt: now,
},
{
id: '29',
french: 'Aujourdhui',
pronunciation: 'o.ʒuʁ.dɥi',
ttsText: 'aujourdhui',
english: 'Today',
chinese: '今天',
partOfSpeech: 'adverb',
example: 'Aujourdhui, il fait beau.',
exampleTranslation: '今天,天气很好。',
category: 'Daily Life',
difficulty: 'beginner',
tags: ['time'],
addedAt: now,
},
{
id: '30',
french: 'Demain',
pronunciation: 'də.mɛ̃',
ttsText: 'demain',
english: 'Tomorrow',
chinese: '明天',
partOfSpeech: 'adverb',
example: 'On se voit demain.',
exampleTranslation: '我们明天见。',
category: 'Daily Life',
difficulty: 'beginner',
tags: ['time'],
addedAt: now,
},
{
id: '31',
french: 'Voyager',
pronunciation: 'vwa.ja.ʒe',
ttsText: 'voyager',
english: 'To travel',
chinese: '旅行',
partOfSpeech: 'verb',
example: 'Nous aimons voyager en train en France.',
exampleTranslation: '我们喜欢在法国坐火车旅行。',
category: 'Travel',
difficulty: 'intermediate',
tags: ['travel', 'verb'],
addedAt: now,
},
{
id: '32',
french: 'Billet',
pronunciation: 'bi.jɛ',
ttsText: 'billet',
english: 'Ticket',
chinese: '票(车票/门票)',
partOfSpeech: 'noun',
example: 'Je voudrais un billet pour Lyon.',
exampleTranslation: '我想要一张去里昂的票。',
category: 'Travel',
difficulty: 'intermediate',
tags: ['travel'],
addedAt: now,
},
{
id: '33',
french: 'Rapide',
pronunciation: 'ʁa.pid',
ttsText: 'rapide',
english: 'Fast',
chinese: '快的',
partOfSpeech: 'adjective',
example: 'Ce train est très rapide.',
exampleTranslation: '这趟火车很快。',
category: 'Travel',
difficulty: 'beginner',
tags: ['travel'],
addedAt: now,
},
{
id: '34',
french: 'Rapidement',
pronunciation: 'ʁa.pid.mɑ̃',
ttsText: 'rapidement',
english: 'Quickly',
chinese: '迅速地',
partOfSpeech: 'adverb',
example: 'Elle parle rapidement mais clairement.',
exampleTranslation: '她说得很快,但很清楚。',
category: 'Daily Life',
difficulty: 'intermediate',
tags: ['adverb'],
addedAt: now,
},
];
await db.words.bulkAdd(defaultWords);

View File

@@ -1,7 +1,12 @@
import { create } from 'zustand';
import type { Word, StudyProgress, StudyStats, DifficultyRating } from '../types/vocabulary';
import { calculateNextReview } from '../utils/srs';
import { db, initDatabase, exportData, importData } from '../db/database';
import type { Word, StudyProgress, StudyStats, DifficultyRating, CategoryStats } from '../types/vocabulary';
import type { TtsSettings } from '../types/settings';
import { DEFAULT_TTS_SETTINGS } from '../types/settings';
import { calculateNextReview, calculateMastery } from '../utils/srs';
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[];
@@ -10,90 +15,320 @@ interface AppState {
currentWordIndex: number;
isFlipped: boolean;
isLoading: boolean;
// Actions
selectedCategory: string | 'all';
searchQuery: string;
tts: TtsSettings;
availableVoices: VoiceOption[];
init: () => Promise<void>;
setWords: (words: Word[]) => void;
addWords: (words: Word[]) => Promise<void>;
deleteWord: (id: string) => Promise<void>;
flipCard: () => void;
rateWord: (rating: DifficultyRating) => Promise<void>;
nextWord: () => void;
getCurrentWord: () => Word | null;
getDueWords: () => Word[];
exportData: () => Promise<any>;
importData: (data: any) => Promise<void>;
getFilteredWords: () => Word[];
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;
}
export const useAppStore = create<AppState>((set, get) => ({
words: [],
progress: new Map(),
stats: {
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,
},
currentWordIndex: 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: emptyStats,
currentWordIndex: -1,
isFlipped: false,
isLoading: true,
selectedCategory: 'all',
searchQuery: '',
tts: DEFAULT_TTS_SETTINGS,
availableVoices: [],
init: async () => {
await initDatabase();
await get().loadSettings();
get().refreshVoices();
// Load words from DB
const wordsFromDB = await db.words.toArray();
// Load progress from DB
const progressFromDB = await db.progress.toArray();
const progressMap = new Map<string, StudyProgress>();
progressFromDB.forEach(p => progressMap.set(p.wordId, p));
progressFromDB.forEach((entry) => progressMap.set(entry.wordId, withMastery(entry)));
// Load stats from DB
const statsFromDB = await db.stats.get('main');
const statsFromDB = (await db.stats.get('main')) as PersistedStats | undefined;
const nextStats = buildStats(wordsFromDB, progressMap, getPersistedStatsOverrides(statsFromDB));
set({
words: wordsFromDB,
progress: progressMap,
stats: statsFromDB || {
totalWords: wordsFromDB.length,
masteredWords: 0,
studyingWords: 0,
streakDays: 0,
todayStudied: 0,
todayNewWords: 0,
},
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() }));
await db.words.bulkAdd(wordsWithDate);
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(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 }
stats: nextStats,
currentWordIndex: getFirstDueWordIndex(allWords, state.progress, state.selectedCategory, state.searchQuery),
isFlipped: false,
});
},
flipCard: () => set((state) => ({ isFlipped: !state.isFlipped })),
deleteWord: async (id) => {
const state = get();
await db.words.delete(id);
await db.progress.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: nextWords,
progress: nextProgress,
stats: nextStats,
currentWordIndex: getFirstDueWordIndex(nextWords, nextProgress, state.selectedCategory, state.searchQuery),
isFlipped: false,
});
},
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,
@@ -102,81 +337,250 @@ export const useAppStore = create<AppState>((set, get) => ({
nextReviewDate: new Date(),
lastStudiedDate: new Date(),
},
rating
rating,
),
);
// Save to IndexedDB
await db.progress.put(newProgress);
await db.progress.put(reviewedProgress);
const newProgressMap = new Map(state.progress);
newProgressMap.set(currentWord.id, newProgress);
const nextProgress = new Map(state.progress);
nextProgress.set(currentWord.id, reviewedProgress);
const newStats = {
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 (dayDifference === null || dayDifference > 1) {
streakDays = 1;
} else if (dayDifference === 1) {
streakDays += 1;
}
const nextStats = buildStats(state.words, nextProgress, {
...state.stats,
todayStudied: state.stats.todayStudied + 1,
};
await db.stats.put({ ...newStats, id: 'main' });
set({
progress: newProgressMap,
isFlipped: false,
stats: newStats,
streakDays,
longestStreak: Math.max(streakDays, state.stats.longestStreak || 0),
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();
return state.words.filter((word) => {
const progress = state.progress.get(word.id);
if (!progress) return true;
return new Date(progress.nextReviewDate) <= now;
});
return getDueWordsFor(state.words, state.progress, state.selectedCategory, state.searchQuery);
},
exportData: async () => {
return await exportData();
getFilteredWords: () => {
const state = get();
return state.words.filter((word) => matchesFilters(word, state.selectedCategory, state.searchQuery));
},
exportData: async () => exportBackupData(),
importData: async (data) => {
await importData(data);
await importBackupData(data);
await get().init();
},
speak: (text: string) => {
if ('speechSynthesis' in window) {
if (!text?.trim() || !('speechSynthesis' in window)) {
return;
}
const synth = window.speechSynthesis;
const { tts } = get();
const pickFrenchVoice = () => {
const voices = synth.getVoices();
if (!voices.length) {
return 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 = () => {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'fr-FR';
utterance.rate = 0.8;
window.speechSynthesis.speak(utterance);
utterance.rate = tts.rate;
utterance.pitch = tts.pitch;
const voice = pickFrenchVoice();
if (voice) {
utterance.voice = voice;
}
synth.cancel();
synth.speak(utterance);
};
if (synth.getVoices().length === 0) {
const handleVoicesChanged = () => {
synth.removeEventListener('voiceschanged', handleVoicesChanged);
speakNow();
};
synth.addEventListener('voiceschanged', handleVoicesChanged);
synth.getVoices();
window.setTimeout(() => {
try {
synth.removeEventListener('voiceschanged', handleVoicesChanged);
} catch {
// ignore
}
speakNow();
}, 250);
return;
}
speakNow();
},
setCategory: (category) =>
set((state) => ({
selectedCategory: category,
currentWordIndex: getFirstDueWordIndex(state.words, state.progress, category, state.searchQuery),
isFlipped: false,
})),
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' });
set({
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((word) => word.category))];
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 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,
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,
})),
});
},
}));

15
src/types/settings.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface TtsSettings {
rate: number; // 0.1 - 2.0
pitch: number; // 0 - 2
voiceURI?: string; // exact voice identifier
autoSpeakWord: boolean;
autoSpeakExample: boolean;
}
export const DEFAULT_TTS_SETTINGS: TtsSettings = {
rate: 0.85,
pitch: 1.0,
voiceURI: undefined,
autoSpeakWord: false,
autoSpeakExample: false,
};

View File

@@ -2,11 +2,20 @@ export interface Word {
id: string;
french: string;
pronunciation?: string;
ttsText?: string;
english: string;
chinese?: string;
partOfSpeech?: string;
example?: string;
exampleTranslation?: string;
examplePronunciation?: string;
notes?: string;
audioUrl?: string;
category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
tags?: string[];
addedAt?: Date;
lastModified?: Date;
}
export interface StudyProgress {
@@ -16,6 +25,7 @@ export interface StudyProgress {
easeFactor: number;
nextReviewDate: Date;
lastStudiedDate: Date;
mastery?: number; // 0-100
}
export interface StudyStats {
@@ -25,6 +35,24 @@ export interface StudyStats {
streakDays: number;
todayStudied: number;
todayNewWords: number;
longestStreak?: number;
lastStudyDate?: Date;
weeklyGoal?: number;
weeklyProgress?: number;
}
export type DifficultyRating = 'again' | 'hard' | 'good' | 'easy';
export interface DailyStats {
date: string;
studied: number;
newWords: number;
accuracy: number;
}
export interface CategoryStats {
category: string;
total: number;
mastered: number;
due: number;
}

View File

@@ -1,23 +1,58 @@
import type { StudyProgress, DifficultyRating } from '../types/vocabulary';
interface SRSConfig {
initialEase: number;
minEase: number;
maxInterval: number;
learningStepInterval: number;
}
const DEFAULT_CONFIG: SRSConfig = {
initialEase: 2.5,
minEase: 1.3,
maxInterval: 365, // 最大间隔 365 天
learningStepInterval: 10, // 学习步骤间隔(分钟)
};
/**
* 改进的 SM-2 算法实现
* 基于 Anki 和 SuperMemo 的研究
*/
export function calculateNextReview(
progress: StudyProgress,
rating: DifficultyRating
rating: DifficultyRating,
config: SRSConfig = DEFAULT_CONFIG
): StudyProgress {
const newProgress = { ...progress };
const { minEase, maxInterval } = config;
// 如果是新单词repetitions === 0使用学习步骤
if (progress.repetitions === 0 && rating === 'again') {
// 标记为"再次",保持在第一步
newProgress.interval = 0;
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.2);
newProgress.nextReviewDate = new Date(Date.now() + DEFAULT_CONFIG.learningStepInterval * 60 * 1000);
newProgress.lastStudiedDate = new Date();
return newProgress;
}
switch (rating) {
case 'again':
// 完全重置,但保留一些学习历史
newProgress.interval = 1;
newProgress.repetitions = 0;
newProgress.easeFactor = Math.max(1.3, progress.easeFactor - 0.2);
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.2);
break;
case 'hard':
newProgress.interval = Math.round(progress.interval * 1.2);
// 困难:间隔增长较慢
newProgress.interval = Math.max(1, Math.round(progress.interval * 1.2));
newProgress.repetitions += 1;
newProgress.easeFactor = Math.max(1.3, progress.easeFactor - 0.15);
newProgress.easeFactor = Math.max(minEase, progress.easeFactor - 0.15);
break;
case 'good':
// 良好:标准 SM-2 算法
if (progress.repetitions === 0) {
newProgress.interval = 1;
} else if (progress.repetitions === 1) {
@@ -27,7 +62,9 @@ export function calculateNextReview(
}
newProgress.repetitions += 1;
break;
case 'easy':
// 简单:间隔增长更快
if (progress.repetitions === 0) {
newProgress.interval = 4;
} else {
@@ -38,9 +75,52 @@ export function calculateNextReview(
break;
}
// 限制最大间隔
newProgress.interval = Math.min(newProgress.interval, maxInterval);
// 确保 easeFactor 在合理范围内
newProgress.easeFactor = Math.max(minEase, Math.min(newProgress.easeFactor, 3.0));
// 计算下次复习日期
const now = new Date();
newProgress.nextReviewDate = new Date(now.getTime() + newProgress.interval * 24 * 60 * 60 * 1000);
newProgress.lastStudiedDate = now;
return newProgress;
}
/**
* 计算单词的掌握程度 (0-100%)
*/
export function calculateMastery(progress: StudyProgress): number {
if (progress.repetitions === 0) return 0;
const baseScore = Math.min(progress.repetitions * 20, 80);
const intervalBonus = Math.min(progress.interval / 30 * 20, 20);
return Math.min(100, Math.round(baseScore + intervalBonus));
}
/**
* 获取复习优先级分数(越高越优先)
*/
export function getReviewPriority(progress: StudyProgress): number {
const now = new Date();
const nextReview = new Date(progress.nextReviewDate);
const overdue = now.getTime() - nextReview.getTime();
// 逾期的单词优先级更高
if (overdue > 0) {
return 1000 + Math.min(overdue / (1000 * 60 * 60), 1000); // 每小时增加 1 点优先级
}
// 即将到期的单词
const timeUntilDue = nextReview.getTime() - now.getTime();
const hoursUntilDue = timeUntilDue / (1000 * 60 * 60);
if (hoursUntilDue < 24) {
return 500 + (24 - hoursUntilDue) * 20;
}
return hoursUntilDue;
}

View File

@@ -7,26 +7,76 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
manifest: {
name: 'French Vocabulary',
short_name: 'FrenchVocab',
description: 'Master French with spaced repetition',
name: '法语词汇学习',
short_name: '法语词汇',
description: '使用间隔重复系统高效学习法语单词',
theme_color: '#3b82f6',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
type: 'image/png',
purpose: 'any maskable'
}
]
],
categories: ['education', 'productivity'],
lang: 'zh-CN',
dir: 'ltr'
}
})
],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
db: ['dexie'],
state: ['zustand']
}
}
},
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
server: {
port: 3000,
host: true
}
})