Phase 28 (c), the last of the phase. A word met inside a Portuguese document is a Portuguese card: migration 0018 mirrors documents.doc_lang onto vocab_words, set server-side from the ownership lookup capture was already making. Every card still reviews — filtering the queue to the half she is learning would drop the words she actually met. Read-aloud was the larger surprise. detectLang routed Han/kana to Chinese and everything else to en-US, so the zh pair was accidentally right and every Latin pair wrong. doc_lang now reaches the client read-only on the document JSON, and docLang(text, verdict) answers for a passage taken out of it — with the script test still winning, because quoted Chinese must never be spelled out one "Chinese letter" at a time. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||
import { docLang as docLocale, speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||
import { usePack, type Line } from '../../i18n'
|
||
import { JournalView } from './JournalView'
|
||
|
||
// 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 t = usePack()
|
||
const [words, setWords] = useState<VocabWord[] | null>(null)
|
||
const [due, setDue] = useState<VocabWord[]>([])
|
||
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<VocabWord[] | null>(null)
|
||
const [cursor, setCursor] = useState(0)
|
||
const [revealed, setRevealed] = useState(false)
|
||
const [expanded, setExpanded] = useState<string | null>(null)
|
||
const [tab, setTab] = useState<'garden' | 'journal'>('garden')
|
||
const panelRef = useFocusTrap<HTMLElement>()
|
||
|
||
// 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 (
|
||
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||
onClick={() => (queue ? setQueue(null) : onClose())}
|
||
/>
|
||
|
||
<aside
|
||
ref={panelRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={t.garden.title}
|
||
tabIndex={-1}
|
||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||
style={{
|
||
background: 'var(--color-surface)',
|
||
borderLeft: '1px solid var(--color-border)',
|
||
boxShadow: 'var(--shadow-soft)',
|
||
}}
|
||
>
|
||
<header
|
||
className="flex items-center justify-between px-5 py-4"
|
||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||
>
|
||
<div>
|
||
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</div>
|
||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{queue ? t.garden.reviewing : tab === 'journal' ? t.journal.subtitle : t.garden.subtitle}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="Close garden"
|
||
onClick={onClose}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
|
||
style={{ color: 'var(--color-muted)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
✕
|
||
</button>
|
||
</header>
|
||
|
||
{/* The two halves of learning: words and phrasing she has collected
|
||
(garden), and how her writing has changed (journal). A review
|
||
session takes over the panel entirely — mid-flashcard is no moment
|
||
to be offered a different page. */}
|
||
{!queue && (
|
||
<div className="flex gap-1 px-4 pt-3">
|
||
{(['garden', 'journal'] as const).map((id) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
onClick={() => setTab(id)}
|
||
aria-pressed={tab === id}
|
||
className="flex-1 rounded-full px-3 py-1.5 text-xs font-bold"
|
||
style={{
|
||
background: tab === id ? 'var(--color-surface-alt)' : 'transparent',
|
||
border: `1px solid ${tab === id ? 'var(--color-border)' : 'transparent'}`,
|
||
color: tab === id ? 'var(--color-plum)' : 'var(--color-muted)',
|
||
}}
|
||
>
|
||
{id === 'garden' ? t.journal.tabGarden : t.journal.tabJournal}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{queue ? (
|
||
<ReviewSession
|
||
queue={queue}
|
||
cursor={cursor}
|
||
revealed={revealed}
|
||
onReveal={() => setRevealed(true)}
|
||
onGrade={grade}
|
||
onQuit={() => setQueue(null)}
|
||
/>
|
||
) : tab === 'journal' ? (
|
||
<JournalView />
|
||
) : (
|
||
<GardenView
|
||
words={words}
|
||
due={due}
|
||
error={error}
|
||
expanded={expanded}
|
||
onToggleExpand={(id) => setExpanded((cur) => (cur === id ? null : id))}
|
||
onStartReview={startReview}
|
||
onRemove={removeWord}
|
||
onOpenDoc={onOpenDoc}
|
||
onRetry={load}
|
||
/>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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])
|
||
const t = usePack()
|
||
return (
|
||
<>
|
||
{due.length > 0 && (
|
||
<div className="px-4 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onStartReview}
|
||
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||
style={{ background: 'var(--color-accent)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||
>
|
||
{t.garden.reviewDue(due.length)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||
{error ? (
|
||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||
Couldn’t load your garden just now.
|
||
<button onClick={onRetry} className="ml-1 font-bold text-plum underline">
|
||
Try again
|
||
</button>
|
||
</div>
|
||
) : words === null ? (
|
||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||
Loading…
|
||
</div>
|
||
) : words.length === 0 ? (
|
||
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||
<p className="text-sm leading-relaxed">
|
||
{t.garden.emptyLead}<br />
|
||
{t.garden.emptyHint}
|
||
</p>
|
||
<p className="mt-2 text-xs">
|
||
Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<ul className="flex flex-col gap-1.5">
|
||
{words.map((w) => {
|
||
const open = expanded === w.id
|
||
const isDue = dueIds.has(w.id)
|
||
return (
|
||
<li key={w.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleExpand(w.id)}
|
||
className="flex w-full items-center gap-2.5 rounded-2xl px-3 py-2.5 text-left"
|
||
style={{
|
||
background: open ? 'var(--color-surface-alt)' : 'transparent',
|
||
border: `1px solid ${open ? 'var(--color-border)' : 'transparent'}`,
|
||
}}
|
||
>
|
||
<span className="text-xl" aria-hidden>
|
||
{blossom(w.reps)}
|
||
</span>
|
||
<span className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex min-w-0 items-center gap-1.5">
|
||
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
|
||
{/* Only a card in her own language is marked. An English
|
||
garden with a badge on every blossom would be a
|
||
garden with no badges at all; the marker exists so
|
||
the rare Portuguese word is legible among them. */}
|
||
{w.lang === 'pair' && (
|
||
<span
|
||
className="shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold lowercase"
|
||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
|
||
>
|
||
{t.nativeName}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{(w.gloss || w.definition) && (
|
||
<span
|
||
className="truncate text-xs"
|
||
style={{
|
||
color: 'var(--color-muted)',
|
||
fontFamily: w.gloss
|
||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||
: 'var(--font-body)',
|
||
}}
|
||
>
|
||
{w.gloss || w.definition}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{isDue && (
|
||
<span
|
||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
||
>
|
||
{t.garden.due}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="mb-1 ml-9 mr-2 mt-1 flex flex-col gap-2 text-xs" style={{ color: 'var(--color-plum)' }}>
|
||
{w.phonetic && <span style={{ color: 'var(--color-muted)' }}>/{w.phonetic}/</span>}
|
||
{w.example && (
|
||
<p className="italic leading-snug" style={{ fontFamily: 'var(--font-body)' }}>
|
||
“{w.example}”
|
||
</p>
|
||
)}
|
||
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||
{t.garden.seen(w.reps, w.interval_days)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{speechSupported() && (
|
||
<button
|
||
type="button"
|
||
onClick={() => speak(w.word, docLocale(w.word, w.lang))}
|
||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||
style={{ background: 'var(--color-surface-alt)' }}
|
||
>
|
||
{t.garden.readAloud}
|
||
</button>
|
||
)}
|
||
{w.doc_id && onOpenDoc && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenDoc(w.doc_id as string)}
|
||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||
style={{ background: 'var(--color-surface-alt)' }}
|
||
>
|
||
{t.garden.source}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(w.id)}
|
||
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
|
||
style={{ color: 'var(--color-muted)' }}
|
||
>
|
||
{t.garden.remove}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{!error && words && words.length > 0 && (
|
||
<div
|
||
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||
>
|
||
{t.garden.growing(words.length)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// --- 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 t = usePack()
|
||
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 (
|
||
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||
<div className="mb-3 flex items-center justify-between text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
<span>
|
||
{cursor + 1} / {queue.length}
|
||
</span>
|
||
<button type="button" onClick={onQuit} className="font-semibold underline">
|
||
{t.garden.end}
|
||
</button>
|
||
</div>
|
||
|
||
{/* The card */}
|
||
<div
|
||
className="flex flex-1 flex-col items-center justify-center rounded-3xl px-5 py-8 text-center"
|
||
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
|
||
>
|
||
<div className="mb-2 text-3xl" aria-hidden>
|
||
{blossom(card.reps)}
|
||
</div>
|
||
<div
|
||
className="text-xl font-extrabold leading-snug text-plum"
|
||
style={{
|
||
fontFamily: production
|
||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||
: 'var(--font-body)',
|
||
}}
|
||
>
|
||
{prompt}
|
||
</div>
|
||
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{production ? t.garden.promptProduction : t.garden.promptRecognition}
|
||
</div>
|
||
|
||
{revealed && (
|
||
<div className="mt-5 w-full border-t pt-4" style={{ borderColor: 'var(--color-border)' }}>
|
||
<div className="flex items-center justify-center gap-2">
|
||
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
||
{speechSupported() && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => speak(card.word, docLocale(card.word, card.lang))}
|
||
aria-label={`Pronounce ${card.word}`}
|
||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||
style={{ background: 'var(--color-surface)' }}
|
||
>
|
||
🔊
|
||
</button>
|
||
{/* A word she has just failed to recall is exactly the word
|
||
worth hearing stretched out. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => speak(card.word, docLocale(card.word, card.lang), true)}
|
||
aria-label={`Pronounce ${card.word} slowly`}
|
||
title={t.garden.readSlowly}
|
||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||
style={{ background: 'var(--color-surface)' }}
|
||
>
|
||
🐢
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
{card.phonetic && (
|
||
<div className="mt-0.5 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
/{card.phonetic}/
|
||
</div>
|
||
)}
|
||
{meaning && (
|
||
<div
|
||
className="mt-1 text-sm font-semibold"
|
||
style={{
|
||
color: 'var(--color-accent-hover)',
|
||
// Chinese gloss gets the CJK stack; an English definition fallback
|
||
// reads better in the body font.
|
||
fontFamily: card.gloss
|
||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||
: 'var(--font-body)',
|
||
}}
|
||
>
|
||
{meaning}
|
||
</div>
|
||
)}
|
||
{card.example && (
|
||
<p className="mt-2 text-xs italic leading-snug" style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
|
||
“{card.example}”
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div className="mt-4">
|
||
{!revealed ? (
|
||
<button
|
||
type="button"
|
||
onClick={onReveal}
|
||
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||
style={{ background: 'var(--color-accent)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||
>
|
||
{t.garden.showAnswer}
|
||
</button>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<GradeButton color="var(--color-peach)" label={t.garden.gradeAgain} onClick={() => onGrade('again')} />
|
||
<GradeButton color="var(--color-mint)" label={t.garden.gradeGood} onClick={() => onGrade('good')} />
|
||
<GradeButton color="var(--color-honey)" label={t.garden.gradeEasy} onClick={() => onGrade('easy')} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GradeButton({ color, label, onClick }: { color: string; label: Line; onClick: () => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
|
||
style={{ background: color }}
|
||
>
|
||
<span className="text-sm font-extrabold">{label.native}</span>
|
||
<span className="text-[11px] font-semibold opacity-80">{label.en}</span>
|
||
</button>
|
||
)
|
||
}
|