Phase 19: the copy stops being hardcoded Mandarin
Every `中文 · English` string moves out of ~29 components into web/src/i18n: one Pack type, a verbatim zh pack, and two ways to read it — usePack() for components, pack() for the modules that build a line when something happens rather than when something renders. Anything with a value in it is a function on the pack rather than a template at the call site, English pluralisation included: word order isn't universal, and a pack author has to be able to move the number. The roster constants (tones, rewrite styles, export formats, companions) keep only value + emoji, so a label can't drift from its key. On the server, internal/llm/lang.go replaces "Simplified Chinese" in the three prompts that actually name her language. pt-PT is spelled "European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt itself, and each Lang carries her word for "why" so the tutor prompt still recognises the question when she asks it her way. pair_lang reaches the model through the row-scoped query each handler already ran — the one that proves she owns the document — rather than a second lookup that could disagree with it. Also records Phase 18's deploy: migration 0011 rehearsed against a copy of the live VPS database, then applied for real.
This commit is contained in:
@@ -2,6 +2,7 @@ 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'
|
||||
import { usePack, type Line } from '../../i18n'
|
||||
|
||||
// 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
|
||||
@@ -40,6 +41,7 @@ function blankOut(sentence: string, word: string): string {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -137,7 +139,7 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="词汇花园 · Vocabulary Garden"
|
||||
aria-label={t.garden.title}
|
||||
tabIndex={-1}
|
||||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||||
style={{
|
||||
@@ -151,9 +153,9 @@ export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">🌷 词汇花园 · Vocabulary Garden</div>
|
||||
<div className="text-base font-extrabold text-plum">{t.garden.titleWithFlower}</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'}
|
||||
{queue ? t.garden.reviewing : t.garden.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -222,6 +224,7 @@ function GardenView({
|
||||
// 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 && (
|
||||
@@ -234,7 +237,7 @@ function GardenView({
|
||||
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 🌸
|
||||
{t.garden.reviewDue(due.length)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -255,8 +258,8 @@ function GardenView({
|
||||
<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 />
|
||||
右键点一个英文单词查它的意思——它就会在这里发芽。
|
||||
{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.
|
||||
@@ -302,7 +305,7 @@ function GardenView({
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
||||
>
|
||||
待复习 · due
|
||||
{t.garden.due}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -316,7 +319,7 @@ function GardenView({
|
||||
</p>
|
||||
)}
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
|
||||
{t.garden.seen(w.reps, w.interval_days)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{speechSupported() && (
|
||||
@@ -326,7 +329,7 @@ function GardenView({
|
||||
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 && (
|
||||
@@ -336,7 +339,7 @@ function GardenView({
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
📄 出处 · Source
|
||||
{t.garden.source}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -345,7 +348,7 @@ function GardenView({
|
||||
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>
|
||||
@@ -362,7 +365,7 @@ function GardenView({
|
||||
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
|
||||
{t.garden.growing(words.length)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -386,6 +389,7 @@ function ReviewSession({
|
||||
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.
|
||||
@@ -410,7 +414,7 @@ function ReviewSession({
|
||||
{cursor + 1} / {queue.length}
|
||||
</span>
|
||||
<button type="button" onClick={onQuit} className="font-semibold underline">
|
||||
结束 · End
|
||||
{t.garden.end}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -433,7 +437,7 @@ function ReviewSession({
|
||||
{prompt}
|
||||
</div>
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
|
||||
{production ? t.garden.promptProduction : t.garden.promptRecognition}
|
||||
</div>
|
||||
|
||||
{revealed && (
|
||||
@@ -492,13 +496,13 @@ function ReviewSession({
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
翻看答案 · Show answer
|
||||
{t.garden.showAnswer}
|
||||
</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')} />
|
||||
<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>
|
||||
@@ -506,7 +510,7 @@ function ReviewSession({
|
||||
)
|
||||
}
|
||||
|
||||
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) {
|
||||
function GradeButton({ color, label, onClick }: { color: string; label: Line; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,8 +518,8 @@ function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en
|
||||
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>
|
||||
<span className="text-sm font-extrabold">{label.native}</span>
|
||||
<span className="text-[11px] font-semibold opacity-80">{label.en}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user