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:
@@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
|
||||
import { DocListItem } from './DocListItem'
|
||||
import { SearchBox } from './SearchBox'
|
||||
import { TagChip } from './TagChip'
|
||||
import { usePack, type Pack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
docs: DocSummary[]
|
||||
@@ -21,10 +22,10 @@ interface Props {
|
||||
|
||||
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||
type SortMode = 'recent' | 'title' | 'longest'
|
||||
const SORTS: { value: SortMode; label: string }[] = [
|
||||
{ value: 'recent', label: '最近 · Recent' },
|
||||
{ value: 'title', label: '标题 · Title' },
|
||||
{ value: 'longest', label: '字数 · Longest' },
|
||||
const SORTS: { value: SortMode; label: (t: Pack) => string }[] = [
|
||||
{ value: 'recent', label: (t) => t.docs.sortRecent },
|
||||
{ value: 'title', label: (t) => t.docs.sortTitle },
|
||||
{ value: 'longest', label: (t) => t.docs.sortLongest },
|
||||
]
|
||||
|
||||
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
|
||||
@@ -41,6 +42,7 @@ export function DocList({
|
||||
onCreateTag,
|
||||
account,
|
||||
}: Props) {
|
||||
const t = usePack()
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
// disappears from the roster.
|
||||
const [filterId, setFilterId] = useState<string | null>(null)
|
||||
@@ -108,7 +110,7 @@ export function DocList({
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
{s.label(t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -144,7 +146,7 @@ export function DocList({
|
||||
className="flex items-center gap-2 px-1 pt-1 text-xs"
|
||||
style={{ color: 'var(--color-muted)', borderTop: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<span className="font-semibold">备份 · Back up all:</span>
|
||||
<span className="font-semibold">{t.docs.backUpAll}</span>
|
||||
<a href={api.exportAllUrl('docx')} download className="font-bold hover:underline" style={{ color: 'var(--color-accent-hover)' }}>
|
||||
Word
|
||||
</a>
|
||||
@@ -169,7 +171,7 @@ export function DocList({
|
||||
className="shrink-0 font-bold hover:underline"
|
||||
style={{ color: 'var(--color-accent-hover)' }}
|
||||
>
|
||||
退出 · Sign out
|
||||
{t.docs.signOut}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import type { DocSummary, Tag, TagColor } from '../../api/client'
|
||||
import { TagChip } from './TagChip'
|
||||
import { TagPicker } from './TagPicker'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
doc: DocSummary
|
||||
@@ -27,6 +28,7 @@ export function DocListItem({
|
||||
onCreateTag,
|
||||
}: Props) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const t = usePack()
|
||||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||||
|
||||
return (
|
||||
@@ -70,7 +72,7 @@ export function DocListItem({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Duplicate document"
|
||||
title="副本 · Duplicate"
|
||||
title={t.docs.duplicate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDuplicate()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, splitSnippet, type SearchResult } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
// Called when a result is chosen — opens that document.
|
||||
@@ -12,6 +13,7 @@ const DEBOUNCE_MS = 220
|
||||
// debounces a full-text query; results drop in below with a highlighted snippet.
|
||||
// Clearing (× or empty) returns the sidebar to the normal document list.
|
||||
export function SearchBox({ onSelect }: Props) {
|
||||
const t = usePack()
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -60,7 +62,7 @@ export function SearchBox({ onSelect }: Props) {
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索 · Search"
|
||||
placeholder={t.docs.searchPlaceholder}
|
||||
aria-label="Search documents"
|
||||
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
|
||||
style={{
|
||||
@@ -88,11 +90,11 @@ export function SearchBox({ onSelect }: Props) {
|
||||
<div className="petal-search-results flex flex-col gap-0.5">
|
||||
{busy && results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
查找中… · Searching…
|
||||
{t.docs.searching}
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到 · No matches
|
||||
{t.docs.noMatches}
|
||||
</p>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
// existing tag to attach/detach it, or type a new name (with a color swatch) to
|
||||
// create-and-attach. Closes on outside click or Escape.
|
||||
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
|
||||
const t = usePack()
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState<TagColor>('rose')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -58,7 +60,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
标签 · Tags
|
||||
{t.docs.tags}
|
||||
</div>
|
||||
|
||||
{roster.length > 0 && (
|
||||
@@ -114,7 +116,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}}
|
||||
placeholder="新标签 · New tag"
|
||||
placeholder={t.docs.newTagPlaceholder}
|
||||
aria-label="New tag name"
|
||||
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user