import { useCallback, useEffect, useMemo, useState } from 'react' import { api, type VocabGrade, type VocabWord } from '../../api/client' import { speak, speechSupported, stopSpeech } from '../../audio/speech' import { useFocusTrap } from '../../hooks/useFocusTrap' // GardenPanel is the vocabulary garden: every word the writer has looked up, // grown into a blossom that opens further the more she remembers it, plus a // gentle spaced-repetition review. Words are captured automatically on lookup // (zero effort), so the garden fills itself as she writes. The sleepy kitten // naps among the blossoms — the same companion gag, at rest in her little // meadow. Bilingual, zh-first, to match Petal's chrome. interface Props { onClose: () => void // Open the document a word was met in (so "where did I see this?" is one tap). onOpenDoc?: (docId: string) => void } // blossom maps a word's successful-review count to how bloomed its flower looks: // a fresh seedling opens into a full blossom as it's remembered. No wilting — a // forgotten word just stops climbing, never shames. function blossom(reps: number): string { if (reps <= 0) return '🌱' if (reps <= 2) return '🌿' if (reps <= 4) return '🌷' if (reps <= 6) return '🌸' return '🌺' } // blankOut hides the target word in its example sentence with a soft blank, so a // flashcard can quiz recall in context. Whole-word, case-insensitive. function blankOut(sentence: string, word: string): string { if (!sentence) return '' try { const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi') return sentence.replace(re, '____') } catch { return sentence } } export function GardenPanel({ onClose, onOpenDoc }: Props) { const [words, setWords] = useState(null) const [due, setDue] = useState([]) const [error, setError] = useState(false) // Review session state: the queue (snapshot of due at start), a cursor, and // whether the current card's answer is revealed. const [queue, setQueue] = useState(null) const [cursor, setCursor] = useState(0) const [revealed, setRevealed] = useState(false) const [expanded, setExpanded] = useState(null) const panelRef = useFocusTrap() // Read-aloud is fire-and-forget, so a word she tapped could still be speaking // when the panel closes. Cancel any in-flight audio on unmount so it can't // outlive the garden. useEffect(() => stopSpeech, []) const load = useCallback(async () => { setError(false) // Settle the two requests independently: a failed /due shouldn't blank the // whole garden when the word list loaded fine. Only listVocab failing is a // true error state; a dueVocab failure just hides the review button. const [allRes, dueRes] = await Promise.allSettled([api.listVocab(), api.dueVocab()]) if (allRes.status === 'fulfilled') setWords(allRes.value) else setError(true) if (dueRes.status === 'fulfilled') setDue(dueRes.value) else setDue([]) }, []) useEffect(() => { void load() }, [load]) // Escape closes the panel (or ends a review session back to the garden). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (queue) setQueue(null) else onClose() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onClose, queue]) const startReview = useCallback(() => { if (due.length === 0) return setQueue(due) setCursor(0) setRevealed(false) }, [due]) const grade = useCallback( async (g: VocabGrade) => { if (!queue) return const card = queue[cursor] if (card) { try { await api.reviewVocab(card.id, g) } catch { /* keep going — a failed grade just won't reschedule */ } } const nextCursor = cursor + 1 if (nextCursor >= queue.length) { // Session done — refresh the garden and drop back to it. setQueue(null) void load() } else { setCursor(nextCursor) setRevealed(false) } }, [queue, cursor, load], ) const removeWord = useCallback(async (id: string) => { setWords((prev) => (prev ? prev.filter((w) => w.id !== id) : prev)) setDue((prev) => prev.filter((w) => w.id !== id)) try { await api.deleteVocab(id) } catch { void load() } }, [load]) return (
(queue ? setQueue(null) : onClose())} />
) } // --- garden grid ------------------------------------------------------------ function GardenView({ words, due, error, expanded, onToggleExpand, onStartReview, onRemove, onOpenDoc, onRetry, }: { words: VocabWord[] | null due: VocabWord[] error: boolean expanded: string | null onToggleExpand: (id: string) => void onStartReview: () => void onRemove: (id: string) => void onOpenDoc?: (docId: string) => void onRetry: () => void }) { // Index the due cards once so the per-word "due" check below is O(1), not a // linear scan of `due` for every word in the garden. const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due]) return ( <> {due.length > 0 && (
)}
{error ? (
Couldn’t load your garden just now.
) : words === null ? (
Loading…
) : words.length === 0 ? (
🌱🐱💤

你的花园还空着。
右键点一个英文单词查它的意思——它就会在这里发芽。

Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.

) : (
    {words.map((w) => { const open = expanded === w.id const isDue = dueIds.has(w.id) return (
  • {open && (
    {w.phonetic && /{w.phonetic}/} {w.example && (

    “{w.example}”

    )}
    复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
    {speechSupported() && ( )} {w.doc_id && onOpenDoc && ( )}
    )}
  • ) })}
)}
{!error && words && words.length > 0 && (
🐱💤 {words.length} 朵花在花园里 · {words.length} blossom{words.length > 1 ? 's' : ''} growing
)} ) } // --- flashcard review ------------------------------------------------------- function ReviewSession({ queue, cursor, revealed, onReveal, onGrade, onQuit, }: { queue: VocabWord[] cursor: number revealed: boolean onReveal: () => void onGrade: (g: VocabGrade) => void onQuit: () => void }) { const card = queue[cursor] // The meaning shown/asked is the Chinese gloss, or the English definition when // a word has no gloss — so definition-only words are still reviewable. const meaning = card?.gloss || card?.definition || '' // Alternate the quiz direction so she practices both recognition (see the // English, recall the meaning) and production (see the meaning, recall the // English word). Parity of the cursor keeps it deterministic within a session. const production = cursor % 2 === 1 && !!meaning const prompt = useMemo(() => { if (!card) return '' if (production) return meaning return card.example ? blankOut(card.example, card.word) : card.word }, [card, production, meaning]) if (!card) return null return (
{cursor + 1} / {queue.length}
{/* The card */}
{blossom(card.reps)}
{prompt}
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
{revealed && (
{card.word} {speechSupported() && ( )}
{card.phonetic && (
/{card.phonetic}/
)} {meaning && (
{meaning}
)} {card.example && (

“{card.example}”

)}
)}
{/* Controls */}
{!revealed ? ( ) : (
onGrade('again')} /> onGrade('good')} /> onGrade('easy')} />
)}
) } function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) { return ( ) }