Backend: - Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/ ServerError); drop the triple-duplicated helpers in docs, suggestions, vocab. ServerError now logs the real error and returns a generic 500 so raw DB/internal errors never reach the client. - vocab capture: validate doc_id ownership (blank -> none, unknown -> 400 instead of a leaked FK 500); rune-safe clamp word/gloss/definition/ phonetic/example. - vocab review(): wrap the read-modify-write in a transaction (TOCTOU). - /api request-size cap via MaxBytesReader middleware (2 MiB), exempting /api/images (own 10 MiB limit). Frontend: - StatusBar: drive the checking/voicing/collocating indicators from one array; llmDown uses !anyBusy. - Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore) on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label. - speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount. Tests: add doc_id-validation and field-clamp coverage; full suite green. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
522 lines
19 KiB
TypeScript
522 lines
19 KiB
TypeScript
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<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 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="词汇花园 · Vocabulary Garden"
|
||
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">🌷 词汇花园 · Vocabulary Garden</div>
|
||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{queue ? '复习中 · Reviewing — recall, then grade yourself' : 'Words you looked up, blooming as you learn them'}
|
||
</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>
|
||
|
||
{queue ? (
|
||
<ReviewSession
|
||
queue={queue}
|
||
cursor={cursor}
|
||
revealed={revealed}
|
||
onReveal={() => setRevealed(true)}
|
||
onGrade={grade}
|
||
onQuit={() => setQueue(null)}
|
||
/>
|
||
) : (
|
||
<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])
|
||
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)')}
|
||
>
|
||
复习 {due.length} 个词 · Review {due.length} due 🌸
|
||
</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">
|
||
你的花园还空着。<br />
|
||
右键点一个英文单词查它的意思——它就会在这里发芽。
|
||
</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="truncate text-sm font-bold text-plum">{w.word}</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' }}
|
||
>
|
||
待复习 · 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)' }}>
|
||
复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{speechSupported() && (
|
||
<button
|
||
type="button"
|
||
onClick={() => speak(w.word)}
|
||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||
style={{ background: 'var(--color-surface-alt)' }}
|
||
>
|
||
🔊 朗读
|
||
</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)' }}
|
||
>
|
||
📄 出处 · 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)' }}
|
||
>
|
||
🗑 移除
|
||
</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)' }}
|
||
>
|
||
🐱💤 {words.length} 朵花在花园里 · {words.length} blossom{words.length > 1 ? 's' : ''} growing
|
||
</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 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">
|
||
结束 · 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 ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
|
||
</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)}
|
||
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>
|
||
)}
|
||
</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)')}
|
||
>
|
||
翻看答案 · Show answer
|
||
</button>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<GradeButton color="var(--color-peach)" zh="再来" en="Again" onClick={() => onGrade('again')} />
|
||
<GradeButton color="var(--color-mint)" zh="记得" en="Good" onClick={() => onGrade('good')} />
|
||
<GradeButton color="var(--color-honey)" zh="太简单" en="Easy" onClick={() => onGrade('easy')} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; 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">{zh}</span>
|
||
<span className="text-[11px] font-semibold opacity-80">{en}</span>
|
||
</button>
|
||
)
|
||
}
|