From 7775541cdba8b50921331f3344241b4c8bf68a0c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Mar 2026 15:45:39 +0800 Subject: [PATCH] feat: stabilize study flow and import experience --- src/App.tsx | 294 +++++++------ src/components/FlashCard.tsx | 67 +-- src/components/ImportModal.tsx | 269 +++++++----- src/components/RatingButtons.tsx | 63 +-- src/components/TtsSettingsModal.tsx | 69 +-- src/stores/appStore.ts | 633 ++++++++++++++++++---------- 6 files changed, 821 insertions(+), 574 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c1fa4a3..3e6b94f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,24 @@ -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 { + const { init, - getCurrentWord, - rateWord, + getCurrentWord, + rateWord, flipCard, isFlipped, 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>((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 (
- Loading... + Loading study session...
); @@ -62,159 +83,182 @@ function App() {
-

Well Done!

-

You have completed all due words for today.

+

Daily queue complete

+

You have reviewed every due word. Import more vocabulary or export a backup.

{stats.todayStudied}
-
Studied Today
+
Reviewed today
{stats.totalWords}
-
Total Words
+
Words in deck
- -
- - setShowImport(false)} - onImport={addWords} - /> + + setShowImport(false)} onImport={handleImport} /> + + setShowTtsSettings(false)} /> ); } return (
-
- {/* Header */} -
-
-
- 🇫🇷 - French Vocabulary +
+
+
+
+
+ French Vocabulary Coach +
+
+ + + +
-
- - - +

Master French one card at a time

+

Spaced repetition, queue awareness, and local-first backups.

+
+ +
+
+
{stats.todayStudied}
+
Reviewed today
+
+
+
{dueWords.length}
+
Due now
+
+
+
{stats.totalWords}
+
Words total
+
+
+
{masteredRate}%
+
Mastered
-

Master French

-

Learn with spaced repetition

-
- {/* Stats */} -
-
-
{stats.todayStudied}
-
Today
+
+
-
-
{dueWords.length}
-
Due
+ +
+
-
-
{stats.totalWords}
-
Total
-
-
- {/* Progress */} -
- -
+ {!isFlipped && ( +
+ + + + + Click the card to reveal the translation + +
+ )} - {/* Flash Card */} -
- -
+ {isFlipped && } - {/* Instructions */} - {!isFlipped && ( -
- +
+
- + - Click card to reveal answer - -
- )} + Powered by the SM-2 review algorithm with local persistence +
+ +
- {/* Rating Buttons */} - {isFlipped && ( - - )} - - {/* Footer */} -
-
- - - - Powered by SM-2 Algorithm • Data persisted locally +
+ +
+ {Object.entries(categorySummary).length > 0 ? ( + Object.entries(categorySummary).map(([category, count]) => ( + + {category} + {count} + + )) + ) : ( + No due categories right now. + )} +
+ +
+ {queuePreview.length > 0 ? ( + queuePreview.map((word, index) => ( +
+
+
+

Queue #{index + 1}

+

{word.french}

+
+ {word.category} +
+

{word.english}

+
+ )) + ) : ( +
+ Your queue is clear. Import more words to keep practicing. +
+ )} +
+
- setShowImport(false)} - onImport={addWords} - /> + setShowImport(false)} onImport={handleImport} /> - setShowTtsSettings(false)} - /> + setShowTtsSettings(false)} />
); } diff --git a/src/components/FlashCard.tsx b/src/components/FlashCard.tsx index c8f6489..1362a25 100644 --- a/src/components/FlashCard.tsx +++ b/src/components/FlashCard.tsx @@ -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) => { + event.stopPropagation(); onSpeak?.(word.ttsText || word.french); }; - const handleSpeakExample = (e: React.MouseEvent) => { - e.stopPropagation(); + const handleSpeakExample = (event: MouseEvent) => { + event.stopPropagation(); if (word.example) { onSpeak?.(word.example); } }; return ( -
+
- {/* Front */}
- - {word.category} - + {word.category} {word.difficulty && ( {word.difficulty} @@ -60,7 +50,7 @@ export function FlashCard({ word, onFlip, onSpeak }: FlashCardProps) {