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.
119 lines
3.6 KiB
TypeScript
119 lines
3.6 KiB
TypeScript
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
|
||
active: boolean
|
||
roster: Tag[]
|
||
onSelect: () => void
|
||
onDelete: () => void
|
||
onDuplicate: () => void
|
||
onToggleTag: (docId: string, tag: Tag) => void
|
||
onCreateTag: (docId: string, name: string, color: TagColor) => void
|
||
}
|
||
|
||
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
|
||
// a delete affordance on hover, and a rose wash when it's the open document.
|
||
export function DocListItem({
|
||
doc,
|
||
active,
|
||
roster,
|
||
onSelect,
|
||
onDelete,
|
||
onDuplicate,
|
||
onToggleTag,
|
||
onCreateTag,
|
||
}: Props) {
|
||
const [picking, setPicking] = useState(false)
|
||
const t = usePack()
|
||
const assignedIds = new Set(doc.tags.map((t) => t.id))
|
||
|
||
return (
|
||
<div
|
||
onClick={onSelect}
|
||
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
|
||
style={{
|
||
borderRadius: 'var(--radius-card)',
|
||
background: active ? 'var(--color-surface)' : 'transparent',
|
||
boxShadow: active ? 'var(--shadow-soft)' : 'none',
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div
|
||
className="truncate text-sm font-semibold"
|
||
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
|
||
>
|
||
{doc.title || 'Untitled'}
|
||
</div>
|
||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
|
||
on hover for the pointer experience. */}
|
||
<button
|
||
type="button"
|
||
aria-label="Tag document"
|
||
title="Tags"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setPicking((v) => !v)
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
🏷️
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Duplicate document"
|
||
title={t.docs.duplicate}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDuplicate()
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
⧉
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Delete document"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete()
|
||
}}
|
||
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
|
||
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
|
||
>
|
||
×
|
||
</button>
|
||
|
||
{picking && (
|
||
<TagPicker
|
||
roster={roster}
|
||
assignedIds={assignedIds}
|
||
onToggle={(t) => onToggleTag(doc.id, t)}
|
||
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
|
||
onClose={() => setPicking(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{doc.tags.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{doc.tags.map((t) => (
|
||
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|