Files
petal/web/src/components/Garden/GardenPanel.tsx
prosolis 8c6bc1604b Code-review follow-ups: httputil, validation caps, a11y
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
2026-06-26 16:59:23 -07:00

522 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)' }}>
Couldnt 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 itll 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>
)
}