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:
+7
-4
@@ -18,6 +18,7 @@ import { useSession } from './hooks/useSession'
|
||||
import { takeDraft } from './lib/drafts'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { usePack } from './i18n'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
@@ -30,6 +31,7 @@ export default function App() {
|
||||
// Who's writing, and whether the server still recognises them. `signedOut`
|
||||
// flips the moment any call comes back 401.
|
||||
const { me, signedOut } = useSession()
|
||||
const t = usePack()
|
||||
// A real account to sign out of, as opposed to the hardcoded local user a
|
||||
// build without auth configured runs as.
|
||||
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
||||
@@ -239,7 +241,8 @@ export default function App() {
|
||||
setDocText('')
|
||||
}, [saveNow, isBlankDraft])
|
||||
|
||||
// Duplicate a document: copy its body/tone into a fresh doc titled "… (副本)",
|
||||
// Duplicate a document: copy its body/tone into a fresh doc under the pack's
|
||||
// "copy" title,
|
||||
// then open the copy. Tags aren't carried over (a fresh start for the copy).
|
||||
const handleDuplicate = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -247,7 +250,7 @@ export default function App() {
|
||||
await saveNow() // flush in case we're duplicating the open doc
|
||||
const src = await api.getDoc(id)
|
||||
const fresh = await api.createDoc()
|
||||
const dupTitle = `${src.title?.trim() || 'Untitled'} (副本)`
|
||||
const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled')
|
||||
const updated = await api.updateDoc(fresh.id, {
|
||||
title: dupTitle,
|
||||
content: src.content,
|
||||
@@ -451,7 +454,7 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🌷</span>
|
||||
<span>词汇花园</span>
|
||||
<span>{t.app.garden}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
||||
</button>
|
||||
</header>
|
||||
@@ -514,7 +517,7 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🕘</span>
|
||||
<span>历史</span>
|
||||
<span>{t.app.history}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· History</span>
|
||||
</button>
|
||||
<div className="petal-no-print">
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// again as an errand, not an error. The editor stays visible behind the scrim
|
||||
// (dimmed, still there) for the same reason: nothing has been taken away.
|
||||
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
// Whether there is unsaved writing waiting on this device, which changes the
|
||||
// reassurance from a promise to a statement of fact.
|
||||
@@ -13,6 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SignInOverlay({ hasDraft }: Props) {
|
||||
const t = usePack()
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -39,21 +42,17 @@ export function SignInOverlay({ hasDraft }: Props) {
|
||||
className="mt-3 text-lg font-bold"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
请重新登录
|
||||
{t.auth.title}
|
||||
</h2>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
Please sign in again
|
||||
{t.auth.titleEn}
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm leading-relaxed" style={{ color: 'var(--color-plum)' }}>
|
||||
{hasDraft
|
||||
? '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。'
|
||||
: '登录状态过期了。你的文字都已经保存好了。'}
|
||||
{hasDraft ? t.auth.bodyWithDraft : t.auth.bodyPlain}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-muted)' }}>
|
||||
{hasDraft
|
||||
? "What you just wrote is safe on this device — it'll save itself once you're back in."
|
||||
: 'Your session expired. Everything you wrote is already saved.'}
|
||||
{hasDraft ? t.auth.bodyWithDraftEn : t.auth.bodyPlainEn}
|
||||
</p>
|
||||
|
||||
<a
|
||||
@@ -63,7 +62,7 @@ export function SignInOverlay({ hasDraft }: Props) {
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
去登录 · Sign in
|
||||
{t.auth.signIn}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCompanion, type Mood } from './useCompanion'
|
||||
import { LottiePlayer } from './LottiePlayer'
|
||||
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||
import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
@@ -30,6 +31,7 @@ const STORAGE_KEY = 'petal.companion'
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
||||
const t = usePack()
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
@@ -115,7 +117,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
选个小伙伴 · Choose a companion
|
||||
{t.companion.choose}
|
||||
</p>
|
||||
{COMPANIONS.map((c) => {
|
||||
const active = c.id === companion.id
|
||||
@@ -138,7 +140,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
>
|
||||
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
|
||||
<span className="flex-1">
|
||||
<span className="font-bold">{c.zh}</span>{' '}
|
||||
<span className="font-bold">{t.companion.names[c.id] ?? c.name}</span>{' '}
|
||||
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
|
||||
</span>
|
||||
{active && <span style={{ color: 'var(--color-accent)' }}>✓</span>}
|
||||
@@ -169,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
className="font-bold leading-snug"
|
||||
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||
>
|
||||
{bubble.zh}
|
||||
{bubble.native}
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 leading-snug"
|
||||
|
||||
@@ -7,8 +7,7 @@ import parrot from './animations/parrot.json'
|
||||
|
||||
export interface Companion {
|
||||
id: string
|
||||
name: string // English label
|
||||
zh: string // Chinese label (she reads Mandarin — north star Note #17)
|
||||
name: string // English label; the writer's-language name lives in the langpack
|
||||
emoji: string // shown in the picker + used as the per-mood fallback base
|
||||
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
|
||||
animations: Partial<Record<Mood, object>>
|
||||
@@ -26,7 +25,6 @@ export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'cat',
|
||||
name: 'Sleepy Cat',
|
||||
zh: '瞌睡猫',
|
||||
emoji: '😴',
|
||||
alwaysAsleep: true,
|
||||
animations: {
|
||||
@@ -40,7 +38,6 @@ export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'dog',
|
||||
name: 'Happy Dog',
|
||||
zh: '开心狗',
|
||||
emoji: '🐶',
|
||||
animations: {
|
||||
idle: happyDog,
|
||||
@@ -53,7 +50,6 @@ export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'wiggle-dog',
|
||||
name: 'Wiggle Dog',
|
||||
zh: '摇尾狗',
|
||||
emoji: '🐕',
|
||||
animations: {
|
||||
idle: wiggleDog,
|
||||
@@ -65,7 +61,6 @@ export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'butterfly',
|
||||
name: 'Butterfly',
|
||||
zh: '蝴蝶',
|
||||
emoji: '🦋',
|
||||
animations: {
|
||||
idle: butterfly,
|
||||
@@ -77,7 +72,6 @@ export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'parrot',
|
||||
name: 'Parrot',
|
||||
zh: '鹦鹉',
|
||||
emoji: '🦜',
|
||||
flip: true, // asset faces left; mirror it to face into the page
|
||||
animations: {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// trust far faster than a missed one earns it, so we only speak up when a
|
||||
// pattern is a high-confidence, genuinely-common English mistake.
|
||||
//
|
||||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||
// Each finding becomes a native-language-first bubble Line (see tips.ts), often
|
||||
// quoting
|
||||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||
// paragraph and not a generic tip jar.
|
||||
//
|
||||
@@ -17,8 +18,13 @@
|
||||
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
||||
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||
|
||||
import { pack } from '../../i18n'
|
||||
import type { Line } from './tips'
|
||||
|
||||
// The pair's prose copy. Read per finding rather than captured once, so a rule
|
||||
// that fires after the writer's language is known speaks the right one.
|
||||
const P = () => pack().prose
|
||||
|
||||
export interface ProseHint extends Line {
|
||||
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||||
// every cadence; the companion remembers recently-shown ids.
|
||||
@@ -108,7 +114,7 @@ function runOns(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `runon:${key(s)}`,
|
||||
rule: 'runon',
|
||||
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||
native: P().longSentence,
|
||||
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||||
})
|
||||
}
|
||||
@@ -170,7 +176,7 @@ function commaSplices(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `splice:${key(m[0])}`,
|
||||
rule: 'splice',
|
||||
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||
native: P().commaSplice,
|
||||
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||||
})
|
||||
}
|
||||
@@ -195,7 +201,7 @@ function antecedents(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `antecedent:${key(s)}`,
|
||||
rule: 'antecedent',
|
||||
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
||||
native: P().vagueThis(m[1]),
|
||||
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||||
})
|
||||
}
|
||||
@@ -226,7 +232,7 @@ function oxford(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `oxford:${key(m[0])}`,
|
||||
rule: 'oxford',
|
||||
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||
native: P().oxfordComma,
|
||||
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||||
})
|
||||
}
|
||||
@@ -246,7 +252,7 @@ function introComma(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `introcomma:${m[1].toLowerCase()}`,
|
||||
rule: 'introcomma',
|
||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||
native: P().transitionComma(m[1]),
|
||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||
})
|
||||
break
|
||||
@@ -265,7 +271,7 @@ function sentenceCaps(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: 'cap-sentence',
|
||||
rule: 'cap-sentence',
|
||||
zh: '每个句子的开头,用大写字母开始吧。',
|
||||
native: P().capitalizeSentence,
|
||||
en: 'Start each new sentence with a capital letter.',
|
||||
})
|
||||
}
|
||||
@@ -289,7 +295,7 @@ function doubles(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `double:${from}:${key(m[0])}`,
|
||||
rule: 'double',
|
||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||
native: P().repeatedWord(m[1]),
|
||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||
fix: { from, to, replacement: m[1] },
|
||||
})
|
||||
@@ -311,7 +317,7 @@ function pronounI(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: 'cap-i',
|
||||
rule: 'cap-i',
|
||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||
native: P().capitalizeI,
|
||||
en: 'In English, “I” is always written as a capital letter.',
|
||||
})
|
||||
}
|
||||
@@ -328,7 +334,7 @@ function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `space-punct:${from}`,
|
||||
rule: 'space-punct',
|
||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||
native: P().spaceBeforePunct,
|
||||
en: 'No space before punctuation — it tucks right against the word.',
|
||||
fix: { from, to, replacement: m[1] + m[2] },
|
||||
})
|
||||
@@ -351,7 +357,7 @@ function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `space-after:${from}`,
|
||||
rule: 'space-after',
|
||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||
native: P().spaceAfterPunct,
|
||||
en: 'Add a space after a comma or period before the next word.',
|
||||
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
||||
})
|
||||
@@ -378,7 +384,7 @@ function articles(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `article-an:${m.index}`,
|
||||
rule: 'article',
|
||||
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
|
||||
native: P().articleAn(m[2]),
|
||||
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
||||
})
|
||||
@@ -389,7 +395,7 @@ function articles(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `article-a:${m.index}`,
|
||||
rule: 'article',
|
||||
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
|
||||
native: P().articleA(m[2]),
|
||||
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
||||
})
|
||||
@@ -410,7 +416,7 @@ function uncountables(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `uncountable:${m.index}`,
|
||||
rule: 'uncountable',
|
||||
zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||
native: P().uncountable(word, singular),
|
||||
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
||||
})
|
||||
@@ -439,7 +445,7 @@ function properCaps(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `propercap:${m.index}`,
|
||||
rule: 'propercap',
|
||||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||
native: P().capitalizeProper(fixed),
|
||||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
||||
})
|
||||
@@ -485,7 +491,7 @@ function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `sva:${m.index}`,
|
||||
rule: 'sva',
|
||||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||
native: P().thirdPersonS(m[2], fixed),
|
||||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
||||
})
|
||||
@@ -515,7 +521,7 @@ function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `plural:${m.index}`,
|
||||
rule: 'plural',
|
||||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||
native: P().pluralAfter(m[1], m[2]),
|
||||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
||||
})
|
||||
@@ -537,7 +543,7 @@ function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `doubledet:${m.index}`,
|
||||
rule: 'doubledet',
|
||||
zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||
native: P().doubleDeterminer(m[1], m[3]),
|
||||
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
||||
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
||||
@@ -561,7 +567,7 @@ function thereIsPlural(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `thereis:${m.index}`,
|
||||
rule: 'thereis',
|
||||
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
|
||||
native: P().thereArePlural(m[2]),
|
||||
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
||||
})
|
||||
@@ -581,7 +587,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `its-own:${m.index}`,
|
||||
rule: 'its',
|
||||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||
native: P().itsOwn,
|
||||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
||||
})
|
||||
@@ -592,7 +598,7 @@ function itsConfusion(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `its-article:${m.index}`,
|
||||
rule: 'its',
|
||||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||
native: P().itsIs(m[1]),
|
||||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
||||
})
|
||||
@@ -617,7 +623,7 @@ function thanThen(text: string, out: ProseHint[]) {
|
||||
out.push({
|
||||
id: `than:${m.index}`,
|
||||
rule: 'than',
|
||||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||
native: P().thanNotThen(m[1]),
|
||||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
||||
})
|
||||
|
||||
@@ -1,93 +1,28 @@
|
||||
// Bilingual companion copy — Mandarin first (she writes/reads in Mandarin), with
|
||||
// a warm English subtitle. Kept gentle and encouraging, never scolding. The
|
||||
// bubble renders zh prominently and en underneath. (Spec north star: CJK is
|
||||
// first-class; Ask Petal & companion answer in Mandarin.)
|
||||
// The companion's line *selection*. The lines themselves moved to the langpack
|
||||
// in Phase 19 (`src/i18n`) — the kitten speaks whichever pair its writer is in,
|
||||
// and the copy for a pair belongs with the rest of that pair's copy rather than
|
||||
// next to the timing rules that decide when to say it.
|
||||
//
|
||||
// Everything here is read at call time, never at import time, so a bubble
|
||||
// composed after /api/me answers is in the right language.
|
||||
|
||||
export interface Line {
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
import { pack, type Line } from '../../i18n'
|
||||
|
||||
// Played when a suggestion is accepted or a milestone hits — pure warmth.
|
||||
export const ENCOURAGEMENTS: Line[] = [
|
||||
{ zh: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||
{ zh: '你写得越来越好了 ✨', en: "You're getting better and better." },
|
||||
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||
{ zh: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ zh: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||
{ zh: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ zh: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ zh: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ zh: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||
]
|
||||
export type { Line }
|
||||
|
||||
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||
// checker (prose.ts) finds nothing concrete to point at in her current text.
|
||||
export const TIPS: Line[] = [
|
||||
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||
{ zh: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
|
||||
{ zh: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
|
||||
{ zh: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
|
||||
{ zh: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||
{ zh: '复数别忘了加 s:two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||
]
|
||||
export const encouragements = (): Line[] => pack().companion.encouragements
|
||||
export const tips = (): Line[] => pack().companion.tips
|
||||
export const breaks = (): Line[] => pack().companion.breaks
|
||||
export const bedtime = (): Line[] => pack().companion.bedtime
|
||||
export const errors = (): Line[] => pack().companion.errors
|
||||
export const greeting = (): Line => pack().companion.greeting
|
||||
export const welcomeBack = (): Line => pack().companion.welcomeBack
|
||||
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
|
||||
|
||||
// Shown after a long stretch of continuous writing.
|
||||
export const BREAKS: Line[] = [
|
||||
{ zh: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||
{ zh: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
|
||||
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
]
|
||||
|
||||
// Shown when she's still writing late at night (≥11pm). Caring, a little
|
||||
// playful — the kitten is always asleep, so "you should be too" lands as a gag,
|
||||
// never a scold. zh stays gentle; the English subtitle carries the wink.
|
||||
export const BEDTIME: Line[] = [
|
||||
{ zh: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// A few old Chinese proverbs on sleep (zh = a faithful rendering of the
|
||||
// English sense — the verified classical 原文 isn't recoverable; swap in the
|
||||
// exact source text if you have it). They read a touch wittier/wiser than the
|
||||
// gentle lines above, which suits a late-night nudge.
|
||||
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||
]
|
||||
|
||||
// First hello when the app opens.
|
||||
export const GREETING: Line = {
|
||||
zh: '嗨~我在这儿陪你写作哦 🐱',
|
||||
en: "Hi! I'm right here keeping you company. 🐱",
|
||||
}
|
||||
|
||||
// Welcome-back nudge after she returns from an idle pause.
|
||||
export const WELCOME_BACK: Line = {
|
||||
zh: '欢迎回来 ✨ 我们继续吧!',
|
||||
en: 'Welcome back ✨ let’s keep going!',
|
||||
}
|
||||
|
||||
// Gentle "haiya, something went wrong" lines — paired with the error sound when
|
||||
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
|
||||
export const ERRORS: Line[] = [
|
||||
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
]
|
||||
|
||||
// Word-count milestones worth a little cheer — every 100 words, on up.
|
||||
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
|
||||
// not copy: the same in every language.
|
||||
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
||||
|
||||
export function milestoneLine(n: number): Line {
|
||||
return {
|
||||
zh: `哇!已经 ${n} 个词了,太厉害了 🎉`,
|
||||
en: `Wow — ${n} words already! Amazing. 🎉`,
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
|
||||
export function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BEDTIME,
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
GREETING,
|
||||
MILESTONES,
|
||||
TIPS,
|
||||
WELCOME_BACK,
|
||||
bedtime,
|
||||
breaks,
|
||||
encouragements,
|
||||
errors,
|
||||
greeting,
|
||||
milestoneLine,
|
||||
pick,
|
||||
tips,
|
||||
welcomeBack,
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
@@ -60,11 +60,11 @@ const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
const now = () => Date.now()
|
||||
|
||||
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||
// length of the native + English lines so denser advice stays up long enough
|
||||
// to actually finish reading.
|
||||
function readBubbleMs(b: Bubble): number {
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
|
||||
const chars = b.zh.length + b.en.length
|
||||
const chars = b.native.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
if (hint) {
|
||||
lastRule.current = hint.rule
|
||||
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
||||
return { native: hint.native, en: hint.en, tone: 'tip' }
|
||||
}
|
||||
return { ...pick(TIPS), tone: 'tip' }
|
||||
return { ...pick(tips()), tone: 'tip' }
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@@ -167,7 +167,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
|
||||
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||
const id = setTimeout(() => say({ ...greeting(), tone: 'tip' }), 1200)
|
||||
return () => clearTimeout(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
@@ -183,7 +183,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
if (sleeping.current) {
|
||||
sleeping.current = false
|
||||
sessionStart.current = now() // a fresh stretch starts on return
|
||||
say({ ...WELCOME_BACK, tone: 'cheer' })
|
||||
say({ ...welcomeBack(), tone: 'cheer' })
|
||||
} else if (mood === 'sleeping') {
|
||||
setMood('idle')
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
firstAccept.current = false
|
||||
return
|
||||
}
|
||||
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
|
||||
say({ ...pick(encouragements()), tone: 'cheer' }, { celebrate: true })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [acceptTick])
|
||||
|
||||
@@ -229,7 +229,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
return
|
||||
}
|
||||
lastError.current = t
|
||||
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
||||
say({ ...pick(errors()), tone: 'error' }, { sound: 'error' })
|
||||
}, [say])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -246,7 +246,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
|
||||
say({ ...pick(encouragements()), tone: 'cheer' }, { proactive: true })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveStatus])
|
||||
@@ -267,7 +267,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
|
||||
lastBreak.current = t
|
||||
sessionStart.current = t
|
||||
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
|
||||
say({ ...pick(breaks()), tone: 'break' }, { proactive: true })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
// returned if she's away/napping).
|
||||
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
||||
lastBedtime.current = t
|
||||
say({ ...pick(BEDTIME), tone: 'bedtime' }, { proactive: true })
|
||||
say({ ...pick(bedtime()), tone: 'bedtime' }, { proactive: true })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
suggestionId: string
|
||||
@@ -19,6 +20,7 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
|
||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||
// token into the latest assistant bubble.
|
||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
const t = usePack()
|
||||
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||
// translation once it lands; `seeding` drives that loading caret.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||
@@ -137,7 +139,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask why… / 问为什么…"
|
||||
placeholder={t.editor.askPlaceholder}
|
||||
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
||||
style={{
|
||||
background: 'var(--color-surface-alt)',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||
@@ -26,6 +27,7 @@ function scrollToActive(editor: Editor) {
|
||||
}
|
||||
|
||||
export function FindReplace({ editor, onClose }: Props) {
|
||||
const t = usePack()
|
||||
const [query, setQuery] = useState('')
|
||||
const [replacement, setReplacement] = useState('')
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
@@ -140,7 +142,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
go(e.shiftKey ? -1 : 1)
|
||||
}
|
||||
}}
|
||||
placeholder="查找 · Find"
|
||||
placeholder={t.editor.findPlaceholder}
|
||||
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -148,7 +150,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
className="w-14 shrink-0 text-center text-xs tabular-nums"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
{count ? `${active + 1} / ${count}` : query ? '无 · 0' : ''}
|
||||
{count ? `${active + 1} / ${count}` : query ? t.editor.findNone : ''}
|
||||
</span>
|
||||
<FindBtn label="Previous match" disabled={!count} onClick={() => go(-1)}>↑</FindBtn>
|
||||
<FindBtn label="Next match" disabled={!count} onClick={() => go(1)}>↓</FindBtn>
|
||||
@@ -156,11 +158,11 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
label="Match case"
|
||||
active={caseSensitive}
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
title="Match case · 区分大小写"
|
||||
title={t.editor.matchCase}
|
||||
>
|
||||
Aa
|
||||
</FindBtn>
|
||||
<FindBtn label="Close" onClick={onClose} title="Close · 关闭">✕</FindBtn>
|
||||
<FindBtn label="Close" onClick={onClose} title={t.editor.close}>✕</FindBtn>
|
||||
</div>
|
||||
|
||||
{showReplace && (
|
||||
@@ -174,7 +176,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
replaceActive()
|
||||
}
|
||||
}}
|
||||
placeholder="替换为 · Replace"
|
||||
placeholder={t.editor.replacePlaceholder}
|
||||
className="min-w-0 flex-1 px-2 py-1.5 text-sm focus:outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -185,7 +187,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-semibold disabled:opacity-40"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
替换
|
||||
{t.editor.replace}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -194,7 +196,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
className="h-8 shrink-0 whitespace-nowrap px-2.5 text-xs font-bold disabled:opacity-40"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: '#fff' }}
|
||||
>
|
||||
全部
|
||||
{t.editor.replaceAll}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
||||
// user writes in Mandarin and English (spec Note #17).
|
||||
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
word: string
|
||||
suggestions: string[]
|
||||
@@ -13,6 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
||||
const t = usePack()
|
||||
const shown = suggestions.slice(0, 5)
|
||||
return (
|
||||
<div
|
||||
@@ -33,7 +36,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'white' }}
|
||||
>
|
||||
拼写 · Spelling
|
||||
{t.editor.spelling}
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
{word}
|
||||
@@ -58,7 +61,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
没有建议 · No suggestions
|
||||
{t.editor.noSuggestions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -68,7 +71,7 @@ export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Pro
|
||||
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
||||
>
|
||||
添加到词典 · Add to dictionary
|
||||
{t.editor.addToDictionary}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { REWRITE_STYLES } from './SelectionBubble'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// RewritePreview shows the result of a tone-rewrite before it touches the
|
||||
// document: the writer's original passage, the model's rewrite beneath it, and
|
||||
@@ -30,6 +31,7 @@ export function RewritePreview({
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: Props) {
|
||||
const t = usePack()
|
||||
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
|
||||
|
||||
return (
|
||||
@@ -54,10 +56,10 @@ export function RewritePreview({
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
<span aria-hidden>{meta.emoji}</span>
|
||||
{meta.zh} · {meta.en}
|
||||
{t.styles[meta.value].native} · {t.styles[meta.value].en}
|
||||
</span>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
改写 · Rewrite
|
||||
{t.editor.rewrite}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -76,14 +78,14 @@ export function RewritePreview({
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
改写中… · Rewriting…
|
||||
{t.editor.rewriting}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="mt-3">
|
||||
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
改写失败,请再试一次 · Couldn’t rewrite — try again
|
||||
{t.editor.rewriteFailed}
|
||||
</p>
|
||||
<div className="mt-2.5 flex justify-end gap-2">
|
||||
<button
|
||||
@@ -92,7 +94,7 @@ export function RewritePreview({
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
取消 · Cancel
|
||||
{t.editor.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -100,7 +102,7 @@ export function RewritePreview({
|
||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
重试 · Retry
|
||||
{t.editor.retry}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +120,7 @@ export function RewritePreview({
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
取消 · Cancel
|
||||
{t.editor.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -126,7 +128,7 @@ export function RewritePreview({
|
||||
className="rounded-full px-3 py-1 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
用这个 · Use this
|
||||
{t.editor.useThis}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
// SelectionBubble floats above a text selection and offers to rewrite it: a
|
||||
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
|
||||
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
|
||||
// prominent "say it naturally" action plus the tone vocabulary mirrored from the
|
||||
// document-tone picker (labels come from the langpack). Picking one hands the style up
|
||||
// to EditorCore, which calls the LLM and shows a preview. Buttons use
|
||||
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
|
||||
// before the handler captures its range.
|
||||
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
export interface RewriteStyle {
|
||||
value: string
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
|
||||
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
|
||||
export const REWRITE_STYLES: RewriteStyle[] = [
|
||||
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
|
||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||
{ value: 'natural', emoji: '✨' },
|
||||
{ value: 'academic', emoji: '🎓' },
|
||||
{ value: 'professional', emoji: '💼' },
|
||||
{ value: 'casual', emoji: '☕' },
|
||||
{ value: 'humorous', emoji: '😄' },
|
||||
{ value: 'creative', emoji: '🎨' },
|
||||
{ value: 'persuasive', emoji: '📣' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
|
||||
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
const pk = usePack()
|
||||
const [natural, ...tones] = REWRITE_STYLES
|
||||
|
||||
return (
|
||||
@@ -64,9 +65,9 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
title="Rewrite the selection to sound more natural"
|
||||
>
|
||||
<span aria-hidden>{natural.emoji}</span>
|
||||
<span>{natural.zh}</span>
|
||||
<span>{pk.styles[natural.value].native}</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
|
||||
{natural.en}
|
||||
{pk.styles[natural.value].en}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -77,7 +78,7 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
onClick={onSpeak}
|
||||
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||
title="朗读所选 · Read selection aloud"
|
||||
title={pk.editor.readSelection}
|
||||
aria-label="Read selection aloud"
|
||||
>
|
||||
🔊
|
||||
@@ -96,10 +97,10 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
|
||||
title={`Rewrite in a ${pk.styles[t.value].en.toLowerCase()} tone`}
|
||||
>
|
||||
<span aria-hidden>{t.emoji}</span>
|
||||
<span>{t.zh}</span>
|
||||
<span>{pk.styles[t.value].native}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// ToneSelect lets the writer set the document's target tone, which steers the
|
||||
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
|
||||
// journal). A small custom dropdown (not a native <select>) so it can carry the
|
||||
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
|
||||
// Mandarin and English. The `value` strings mirror the backend's tone keys.
|
||||
// bilingual labels and emoji that match Petal's chrome — the writer reads her
|
||||
// own language and English. The `value` strings mirror the backend's tone keys;
|
||||
// the labels themselves live in the langpack, keyed by the same value.
|
||||
|
||||
export interface ToneOption {
|
||||
value: string
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
|
||||
// means no steering (Petal's default friendly ESL advice).
|
||||
export const TONES: ToneOption[] = [
|
||||
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
|
||||
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
|
||||
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
|
||||
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
|
||||
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
|
||||
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
|
||||
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
|
||||
{ value: 'general', emoji: '🌸' },
|
||||
{ value: 'academic', emoji: '🎓' },
|
||||
{ value: 'professional', emoji: '💼' },
|
||||
{ value: 'casual', emoji: '☕' },
|
||||
{ value: 'humorous', emoji: '😄' },
|
||||
{ value: 'creative', emoji: '🎨' },
|
||||
{ value: 'persuasive', emoji: '📣' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
@@ -31,6 +32,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ToneSelect({ value, onChange }: Props) {
|
||||
const pk = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||
@@ -63,8 +65,8 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
title="Set the tone — Petal tailors its advice to match"
|
||||
>
|
||||
<span aria-hidden>{current.emoji}</span>
|
||||
<span>{current.zh}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
|
||||
<span>{pk.tones[current.value].native}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· {pk.tones[current.value].en}</span>
|
||||
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
⌄
|
||||
</span>
|
||||
@@ -105,9 +107,9 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{t.emoji}</span>
|
||||
<span>{t.zh}</span>
|
||||
<span>{pk.tones[t.value].native}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
{t.en}
|
||||
{pk.tones[t.value].en}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WordInfo } from '../../api/client'
|
||||
import { speak, speechSupported } from '../../audio/speech'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// WordCard is the right-click popover for any word: its dictionary definition(s)
|
||||
// on top and tappable synonym pills below. Clicking a synonym replaces the word
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
|
||||
const t = usePack()
|
||||
const definitions = info?.definitions ?? []
|
||||
const synonyms = info?.synonyms ?? []
|
||||
const gloss = info?.gloss ?? ''
|
||||
@@ -47,7 +49,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
词语 · Word
|
||||
{t.editor.word}
|
||||
</span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
{word}
|
||||
@@ -59,7 +61,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
onClick={onToggleSave}
|
||||
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
|
||||
aria-pressed={saved}
|
||||
title={saved ? '已在词汇花园 · In your garden (tap to remove)' : '加入词汇花园 · Save to garden'}
|
||||
title={saved ? t.editor.inGarden : t.editor.saveToGarden}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
|
||||
style={{
|
||||
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||
@@ -73,7 +75,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
type="button"
|
||||
onClick={() => speak(word)}
|
||||
aria-label={`Pronounce ${word}`}
|
||||
title="朗读 · Read aloud"
|
||||
title={t.editor.readAloud}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
@@ -111,14 +113,14 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
查找中… · Looking up…
|
||||
{t.editor.lookingUp}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{definitions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
释义 · Definition
|
||||
{t.editor.definition}
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{definitions.map((m, i) => (
|
||||
@@ -143,7 +145,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
{synonyms.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
近义词 · Synonyms <span className="font-normal">(点击替换 · tap to swap)</span>
|
||||
{t.editor.synonyms} <span className="font-normal">({t.editor.tapToSwap})</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{synonyms.map((s) => (
|
||||
@@ -165,7 +167,7 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
|
||||
{empty && (
|
||||
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
没有找到这个词 · Nothing found for this word
|
||||
{t.editor.nothingFound}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type ExportFormat } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// ExportMenu is the "get your writing out of Petal" dropdown. File formats are
|
||||
// plain <a download> links to the server's export endpoint (which sets the
|
||||
// Content-Disposition filename, CJK and all). "Print / Save as PDF" calls the
|
||||
// browser print dialog against the print stylesheet, so PDF stays CJK-safe with
|
||||
// no server-side font embedding. Bilingual zh·en labels match Petal's chrome.
|
||||
// no server-side font embedding. Labels come from the writer's langpack.
|
||||
|
||||
interface FormatOption {
|
||||
format: ExportFormat
|
||||
emoji: string
|
||||
zh: string
|
||||
en: string
|
||||
}
|
||||
|
||||
const FORMATS: FormatOption[] = [
|
||||
{ format: 'docx', emoji: '📄', zh: 'Word 文档', en: 'Word (.docx)' },
|
||||
{ format: 'md', emoji: '📝', zh: 'Markdown', en: 'Markdown (.md)' },
|
||||
{ format: 'html', emoji: '🌐', zh: '网页', en: 'Web page (.html)' },
|
||||
{ format: 'txt', emoji: '🧾', zh: '纯文本', en: 'Plain text (.txt)' },
|
||||
{ format: 'docx', emoji: '📄' },
|
||||
{ format: 'md', emoji: '📝' },
|
||||
{ format: 'html', emoji: '🌐' },
|
||||
{ format: 'txt', emoji: '🧾' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +25,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ExportMenu({ docId }: Props) {
|
||||
const t = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ExportMenu({ docId }: Props) {
|
||||
title="Save or print your writing"
|
||||
>
|
||||
<span aria-hidden>⬇</span>
|
||||
<span>导出</span>
|
||||
<span>{t.exports.label}</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||
</button>
|
||||
|
||||
@@ -85,9 +85,9 @@ export function ExportMenu({ docId }: Props) {
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span aria-hidden>{f.emoji}</span>
|
||||
<span>{f.zh}</span>
|
||||
<span>{t.exports.formats[f.format].native}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
{f.en}
|
||||
{t.exports.formats[f.format].en}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -108,7 +108,7 @@ export function ExportMenu({ docId }: Props) {
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span aria-hidden>🖨️</span>
|
||||
<span>打印 / PDF</span>
|
||||
<span>{t.exports.print}</span>
|
||||
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
|
||||
Print / PDF
|
||||
</span>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import { usePack, type Pack } from '../../i18n'
|
||||
|
||||
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||
// document, newest first, with a one-click preview and restore. It's the safety
|
||||
@@ -16,27 +17,29 @@ interface Props {
|
||||
onRestored: (doc: Document) => void
|
||||
}
|
||||
|
||||
// kindLabel maps a snapshot kind to its bilingual badge + accent color.
|
||||
const KIND: Record<DocumentVersion['kind'], { zh: string; en: string; color: string }> = {
|
||||
manual: { zh: '保存点', en: 'Saved point', color: 'var(--color-accent)' },
|
||||
auto: { zh: '自动', en: 'Auto', color: 'var(--color-muted)' },
|
||||
pre_restore: { zh: '恢复前', en: 'Before restore', color: 'var(--color-lavender)' },
|
||||
// The accent color of each snapshot kind's badge. Its label is copy and lives in
|
||||
// the langpack, keyed by the same kind.
|
||||
const KIND_COLOR: Record<DocumentVersion['kind'], string> = {
|
||||
manual: 'var(--color-accent)',
|
||||
auto: 'var(--color-muted)',
|
||||
pre_restore: 'var(--color-lavender)',
|
||||
}
|
||||
|
||||
// relativeTime renders a UTC timestamp as a gentle "x minutes ago" string.
|
||||
function relativeTime(iso: string): string {
|
||||
function relativeTime(iso: string, t: Pack): string {
|
||||
const then = new Date(iso).getTime()
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||
if (secs < 60) return 'just now · 刚刚'
|
||||
if (secs < 60) return t.history.justNow
|
||||
const mins = Math.round(secs / 60)
|
||||
if (mins < 60) return `${mins} min ago · ${mins} 分钟前`
|
||||
if (mins < 60) return t.history.minutesAgo(mins)
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} hr ago · ${hrs} 小时前`
|
||||
if (hrs < 24) return t.history.hoursAgo(hrs)
|
||||
const days = Math.round(hrs / 24)
|
||||
return `${days} day${days > 1 ? 's' : ''} ago · ${days} 天前`
|
||||
return t.history.daysAgo(days)
|
||||
}
|
||||
|
||||
export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
const t = usePack()
|
||||
const [versions, setVersions] = useState<DocumentVersion[] | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||
@@ -125,7 +128,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="历史 · History"
|
||||
aria-label={t.history.title}
|
||||
tabIndex={-1}
|
||||
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||
style={{
|
||||
@@ -139,7 +142,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">历史 · History</div>
|
||||
<div className="text-base font-extrabold text-plum">{t.history.title}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
Every saved moment — nothing is ever lost 🌸
|
||||
</div>
|
||||
@@ -176,7 +179,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{versions.map((v) => {
|
||||
const k = KIND[v.kind]
|
||||
const color = KIND_COLOR[v.kind]
|
||||
const active = selected?.id === v.id
|
||||
return (
|
||||
<li key={v.id}>
|
||||
@@ -201,13 +204,13 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
<span className="truncate text-sm font-bold text-plum">{v.title || 'Untitled'}</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ background: k.color, color: '#fff' }}
|
||||
style={{ background: color, color: '#fff' }}
|
||||
>
|
||||
{k.zh} · {k.en}
|
||||
{t.history.kinds[v.kind].native} · {t.history.kinds[v.kind].en}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
<span>{relativeTime(v.created_at)}</span>
|
||||
<span>{relativeTime(v.created_at, t)}</span>
|
||||
<span>{v.word_count} words</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -224,7 +227,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
style={{ borderTop: '1px solid var(--color-border)', background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
<div className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
预览 · Preview
|
||||
{t.history.preview}
|
||||
</div>
|
||||
<div
|
||||
className="mb-3 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-xl px-3 py-2 text-sm"
|
||||
@@ -245,7 +248,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
{busy ? 'Restoring…' : '恢复这个版本 · Restore this version'}
|
||||
{busy ? t.history.restoring : t.history.restoreThis}
|
||||
</button>
|
||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
Your current draft is saved first, so this is undoable.
|
||||
@@ -270,7 +273,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
📜 写作证明 · Writing passport
|
||||
{t.history.passport}
|
||||
</a>
|
||||
<div className="mt-1.5 text-center text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
A report showing how this draft grew, session by session.
|
||||
@@ -290,7 +293,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
/>
|
||||
<span>
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
保留完整历史 · Keep full history
|
||||
{t.history.keepFullHistory}
|
||||
</span>
|
||||
<br />
|
||||
Never delete old snapshots of this document, so the record stays complete.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
|
||||
// the sound toggle in the status bar. Some people find the drifting petals
|
||||
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
|
||||
// Mandarin first), and the choice persists across reloads.
|
||||
export function PetalsToggle() {
|
||||
const t = usePack()
|
||||
const [on, setOn] = useState(isPetalsEnabled)
|
||||
|
||||
// Stay in sync if the setting is flipped elsewhere.
|
||||
@@ -22,7 +24,7 @@ export function PetalsToggle() {
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={on}
|
||||
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
|
||||
title={on ? t.status.petalsOn : t.status.petalsOff}
|
||||
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
|
||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||
// when sounds are turned back on so the choice is audible.
|
||||
export function SoundToggle() {
|
||||
const t = usePack()
|
||||
const [on, setOn] = useState(isSoundEnabled)
|
||||
|
||||
// Stay in sync if the setting is flipped elsewhere.
|
||||
@@ -22,7 +24,7 @@ export function SoundToggle() {
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={on}
|
||||
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
||||
title={on ? t.status.soundsOn : t.status.soundsOff}
|
||||
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { computeStats, gradeBand } from './stats'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// StatsPanel is the popover that opens above the word count: a small grid of
|
||||
// writing statistics computed from the live document. Bilingual zh·en labels to
|
||||
// match Petal's chrome. Reading level shows a friendly band, not just a number.
|
||||
// writing statistics computed from the live document. Labels are the writer's
|
||||
// pair; the reading level shows a friendly band, not just a number.
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
@@ -11,33 +12,34 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Row {
|
||||
zh: string
|
||||
native: string
|
||||
en: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function StatsPanel({ text, wordCount }: Props) {
|
||||
const t = usePack()
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const L = t.status.stats
|
||||
const s = computeStats(text, wordCount)
|
||||
const band = gradeBand(s.gradeLevel)
|
||||
const fmt = (n: number, d = 0) =>
|
||||
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||
return [
|
||||
{ zh: '字数', en: 'Words', value: fmt(s.words) },
|
||||
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
|
||||
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
|
||||
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
|
||||
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
|
||||
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
|
||||
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
|
||||
{ ...L.words, value: fmt(s.words) },
|
||||
{ ...L.characters, value: fmt(s.characters) },
|
||||
{ ...L.sentences, value: fmt(s.sentences) },
|
||||
{ ...L.paragraphs, value: fmt(s.paragraphs) },
|
||||
{ ...L.pages, value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
|
||||
{ ...L.readingTime, value: readingTime(s.readingTimeMin) },
|
||||
{ ...L.avgWordLength, value: `${fmt(s.avgWordLength, 1)}` },
|
||||
{ ...L.variety, value: `${fmt(s.variety * 100)}%` },
|
||||
{
|
||||
zh: '阅读难度',
|
||||
en: 'Reading level',
|
||||
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||
...L.readability,
|
||||
value: s.words > 0 ? `${t.status.readability[band].en} · ${fmt(s.gradeLevel, 1)}` : '—',
|
||||
},
|
||||
]
|
||||
}, [text, wordCount])
|
||||
}, [text, wordCount, t])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -54,14 +56,14 @@ export function StatsPanel({ text, wordCount }: Props) {
|
||||
}}
|
||||
>
|
||||
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
写作统计 · Writing stats
|
||||
{t.status.statsTitle}
|
||||
</p>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<dt style={{ color: 'var(--color-muted)' }}>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
|
||||
{r.zh}
|
||||
{r.native}
|
||||
</span>{' '}
|
||||
{r.en}
|
||||
</dt>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
import { PetalsToggle } from './PetalsToggle'
|
||||
import { SoundToggle } from './SoundToggle'
|
||||
import { usePack, type Pack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
@@ -20,16 +21,19 @@ interface Props {
|
||||
llmDown: boolean
|
||||
}
|
||||
|
||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
idle: '',
|
||||
pending: 'Editing…',
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved just now',
|
||||
error: "Couldn't save",
|
||||
// The session lapsed. Say where the writing is, not what failed — it's safe
|
||||
// on this device and goes up the moment she signs back in.
|
||||
'signed-out': '已保存在本机 · Kept on this device',
|
||||
}
|
||||
// Save-state labels. English except for the lapsed-session case, which is the
|
||||
// one a writer reads with her heart in her mouth — that one comes from her pack.
|
||||
const saveLabel = (status: SaveStatus, t: Pack): string =>
|
||||
({
|
||||
idle: '',
|
||||
pending: 'Editing…',
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved just now',
|
||||
error: "Couldn't save",
|
||||
// The session lapsed. Say where the writing is, not what failed — it's safe
|
||||
// on this device and goes up the moment she signs back in.
|
||||
'signed-out': t.status.savedLocally,
|
||||
})[status]
|
||||
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
@@ -45,7 +49,8 @@ interface Indicator {
|
||||
}
|
||||
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
const t = usePack()
|
||||
const label = saveLabel(saveStatus, t)
|
||||
|
||||
const indicators: Indicator[] = [
|
||||
{
|
||||
@@ -127,8 +132,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
|
||||
style={{ color: 'var(--color-honey)' }}
|
||||
>
|
||||
<span aria-hidden>🌙</span>
|
||||
<span>小助手在休息</span>
|
||||
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
|
||||
<span>{t.status.helperRestingNative}</span>
|
||||
<span style={{ opacity: 0.75 }}>{t.status.helperRestingEn}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -82,11 +82,14 @@ export function computeStats(text: string, wordCount: number): WritingStats {
|
||||
}
|
||||
}
|
||||
|
||||
// gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor —
|
||||
// far more useful to an ESL writer than a bare number.
|
||||
export function gradeBand(grade: number): { zh: string; en: string } {
|
||||
if (grade <= 5) return { zh: '简单', en: 'Easy' }
|
||||
if (grade <= 8) return { zh: '标准', en: 'Standard' }
|
||||
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
|
||||
return { zh: '较难', en: 'Advanced' }
|
||||
// gradeBand turns a Flesch–Kincaid grade into a friendly band — far more useful
|
||||
// to an ESL writer than a bare number. It returns the band's name, not its
|
||||
// label: the wording belongs to the writer's langpack.
|
||||
export type ReadabilityBand = 'easy' | 'standard' | 'fairlyHard' | 'advanced'
|
||||
|
||||
export function gradeBand(grade: number): ReadabilityBand {
|
||||
if (grade <= 5) return 'easy'
|
||||
if (grade <= 8) return 'standard'
|
||||
if (grade <= 12) return 'fairlyHard'
|
||||
return 'advanced'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { uploadImageInto } from '../Editor/EditorCore'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
editor: Editor | null
|
||||
@@ -169,6 +170,7 @@ function Swatch({
|
||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||
// the current selection without re-rendering the whole tree on every keystroke.
|
||||
export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, collocating }: Props) {
|
||||
const t = usePack()
|
||||
// Which popover (if any) is open. Only one at a time.
|
||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
@@ -231,7 +233,7 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
||||
if (menu === 'outline') {
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || '(无标题)', pos })
|
||||
headings.push({ level: (node.attrs.level as number) || 1, text: node.textContent || t.toolbar.untitledHeading, pos })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -544,11 +546,11 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
||||
>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<p className="mb-1.5 px-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||
大纲 · Outline
|
||||
{t.toolbar.outline}
|
||||
</p>
|
||||
{headings.length === 0 ? (
|
||||
<p className="px-1 py-2 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
用 H1/H2/H3 添加标题,这里就会出现导航。<br />
|
||||
{t.toolbar.outlineHint}<br />
|
||||
Add headings to navigate them here.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
// UpdateBanner gently floats down from the top when a newer build has been
|
||||
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
||||
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
||||
// the next deploy (or reload) will surface a fresh one.
|
||||
export function UpdateBanner() {
|
||||
const t = usePack()
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
if (dismissed) return null
|
||||
|
||||
@@ -30,7 +32,7 @@ export function UpdateBanner() {
|
||||
className="truncate text-sm font-bold leading-snug"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
有新版本啦
|
||||
{t.update.available}
|
||||
</p>
|
||||
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
A new version is ready — refresh to update.
|
||||
@@ -44,13 +46,13 @@ export function UpdateBanner() {
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
刷新 · Refresh
|
||||
{t.update.refresh}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="稍后再说 · Dismiss"
|
||||
title="稍后再说 · Dismiss"
|
||||
aria-label={t.update.dismiss}
|
||||
title={t.update.dismiss}
|
||||
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, onUnauthorized, type Me } from '../api/client'
|
||||
import { setPrefsScope } from '../lib/prefs'
|
||||
import { setPackLang } from '../i18n'
|
||||
|
||||
// useSession tracks who is writing, and notices the moment the server stops
|
||||
// recognising them.
|
||||
@@ -25,6 +26,10 @@ export function useSession() {
|
||||
// shared — and the first account on this browser inherits whatever was
|
||||
// set back when Petal had no accounts at all.
|
||||
setPrefsScope(user.id)
|
||||
// …and so does the language Petal speaks back. Until this point the app
|
||||
// renders the default pack; a writer on another pair sees her own copy
|
||||
// from here on, without a reload.
|
||||
setPackLang(user.pair_lang)
|
||||
setMe(user)
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
|
||||
import { zh } from './packs/zh'
|
||||
|
||||
beforeEach(() => {
|
||||
resetPackForTests()
|
||||
})
|
||||
|
||||
describe('pack selection', () => {
|
||||
it('speaks the default pair before /api/me answers', () => {
|
||||
// Modules that build copy at import time (the companion, the prose checker)
|
||||
// read the pack before the session is known. That read must not be blank.
|
||||
expect(pack()).toBe(zh)
|
||||
expect(pack().code).toBe('zh')
|
||||
})
|
||||
|
||||
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||||
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||||
// of its translation. She should still get a working editor.
|
||||
setPackLang('pt-PT')
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang('klingon')
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang('')
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang(null)
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang(undefined)
|
||||
expect(pack()).toBe(zh)
|
||||
})
|
||||
|
||||
// Only one pack ships today, so a *real* switch can't be exercised yet; what
|
||||
// can be is the other half of that contract — that a no-op never wakes every
|
||||
// reader in the app. The switching path gets its test when pt-PT lands.
|
||||
it('never notifies readers when nothing actually changed', () => {
|
||||
const seen = vi.fn()
|
||||
onPackChange(seen)
|
||||
|
||||
setPackLang('zh') // already the current pack — nothing changed
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
// Standing in for a second pack until one ships: switching to something
|
||||
// unshipped resolves back to zh, which is also not a change.
|
||||
setPackLang('fr')
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const seen = vi.fn()
|
||||
const off = onPackChange(seen)
|
||||
off()
|
||||
setPackLang('zh')
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the zh pack', () => {
|
||||
// Sentinels: a handful of strings copied from the pre-Phase-19 source. The
|
||||
// point of the extraction was that nothing she reads changed, and a reworded
|
||||
// label would otherwise be invisible in a diff of this size.
|
||||
it('carries the original copy verbatim', () => {
|
||||
expect(zh.docs.searchPlaceholder).toBe('搜索 · Search')
|
||||
expect(zh.editor.addToDictionary).toBe('添加到词典 · Add to dictionary')
|
||||
expect(zh.status.savedLocally).toBe('已保存在本机 · Kept on this device')
|
||||
expect(zh.garden.titleWithFlower).toBe('🌷 词汇花园 · Vocabulary Garden')
|
||||
expect(zh.companion.greeting).toEqual({
|
||||
native: '嗨~我在这儿陪你写作哦 🐱',
|
||||
en: "Hi! I'm right here keeping you company. 🐱",
|
||||
})
|
||||
expect(zh.prose.itsOwn).toBe('“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。')
|
||||
})
|
||||
|
||||
it('renders its interpolated lines with the value in place', () => {
|
||||
expect(zh.app.duplicateTitle('Spring')).toBe('Spring (副本)')
|
||||
expect(zh.companion.milestone(300).native).toBe('哇!已经 300 个词了,太厉害了 🎉')
|
||||
expect(zh.prose.articleAn('apple')).toContain('“an apple”')
|
||||
expect(zh.prose.uncountable('informations', 'information')).toContain('“information”')
|
||||
expect(zh.garden.reviewDue(4)).toBe('复习 4 个词 · Review 4 due 🌸')
|
||||
// English pluralisation is the pack's job, not the call site's.
|
||||
expect(zh.garden.growing(1)).toContain('1 blossom growing')
|
||||
expect(zh.garden.growing(2)).toContain('2 blossoms growing')
|
||||
expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前')
|
||||
expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前')
|
||||
})
|
||||
|
||||
// A pack with a hole in it renders an empty label rather than failing, which
|
||||
// is exactly the kind of thing that reaches production. Types catch a missing
|
||||
// *key*; only this catches an empty *value*.
|
||||
it('has no empty strings anywhere', () => {
|
||||
const empties: string[] = []
|
||||
const walk = (node: unknown, path: string) => {
|
||||
if (typeof node === 'string') {
|
||||
if (node.trim() === '') empties.push(path)
|
||||
return
|
||||
}
|
||||
if (typeof node === 'function') return // exercised above
|
||||
if (node && typeof node === 'object') {
|
||||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
||||
}
|
||||
}
|
||||
walk(zh, '')
|
||||
expect(empties).toEqual([])
|
||||
})
|
||||
|
||||
it('labels every companion in the roster and every tone the editor offers', async () => {
|
||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||
for (const c of COMPANIONS) {
|
||||
expect(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||||
}
|
||||
|
||||
const { TONES } = await import('../components/Editor/ToneSelect')
|
||||
for (const tone of TONES) {
|
||||
expect(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||||
}
|
||||
|
||||
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
||||
for (const style of REWRITE_STYLES) {
|
||||
expect(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// Which langpack Petal is speaking, and how the app asks for it.
|
||||
//
|
||||
// The pair language is a property of the *writer* (`users.pair_lang`), so it
|
||||
// isn't known until /api/me answers. That's the same timing problem prefs.ts
|
||||
// solved for the mute toggle, and the answer is the same shape: a module-level
|
||||
// value with a setter and a subscription, rather than blocking startup on the
|
||||
// network before anything can render.
|
||||
//
|
||||
// The difference is what a pre-answer read should see. A preference has a right
|
||||
// answer in localStorage; a langpack does not, and guessing wrong would flash
|
||||
// one language and then swap it. So the default is simply zh — every writer
|
||||
// today is on the zh pair, `users.pair_lang` defaults to 'zh', and a new pair
|
||||
// only ever arrives with an answer from the server behind it.
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import type { Pack, PairLang } from './types'
|
||||
import { zh } from './packs/zh'
|
||||
|
||||
export type { Pack, PairLang, Line } from './types'
|
||||
|
||||
// Every pack Petal ships. pt-PT, fr and es land here in Phase 21 — adding one
|
||||
// is this line plus the file, and TypeScript then names every string it owes.
|
||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh }
|
||||
|
||||
const DEFAULT_LANG: PairLang = 'zh'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
let current: Pack = zh
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
// pack returns the langpack in force right now. For code outside React —
|
||||
// modules that build a line when something happens rather than when something
|
||||
// renders (the companion, the prose checker).
|
||||
export function pack(): Pack {
|
||||
return current
|
||||
}
|
||||
|
||||
// setPackLang names the pair this writer is in. Called once, from useSession,
|
||||
// as soon as /api/me answers.
|
||||
//
|
||||
// An unknown or unshipped language falls back to the default rather than
|
||||
// throwing or rendering blanks: a `pair_lang` Petal has no pack for is a
|
||||
// deployment that got ahead of its copy, and the writer should still see a
|
||||
// working editor in a language she may not prefer.
|
||||
export function setPackLang(lang: string | undefined | null): void {
|
||||
const next = PACKS[(lang || DEFAULT_LANG) as PairLang] ?? PACKS[DEFAULT_LANG] ?? zh
|
||||
if (next === current) return
|
||||
current = next
|
||||
listeners.forEach((fn) => fn())
|
||||
}
|
||||
|
||||
// onPackChange fires when the pair language changes. Returns an unsubscribe.
|
||||
export function onPackChange(fn: Listener): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// usePack is the React side: a component reads copy with it and re-renders if
|
||||
// the pair language arrives (or changes) later.
|
||||
export function usePack(): Pack {
|
||||
return useSyncExternalStore(onPackChange, pack, pack)
|
||||
}
|
||||
|
||||
// resetPackForTests puts the module back to its initial state. Tests only.
|
||||
export function resetPackForTests(): void {
|
||||
current = zh
|
||||
listeners.clear()
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// The Mandarin pack — Petal's original copy, moved here unchanged.
|
||||
//
|
||||
// Every string in this file was a literal somewhere in the app before Phase 19.
|
||||
// It is deliberately verbatim, down to the punctuation: the zh pair is in daily
|
||||
// use, and a langpack extraction that quietly reworded anything would be a
|
||||
// product change wearing a refactor's clothes.
|
||||
//
|
||||
// Mandarin first, English underneath — she reads Chinese faster, and the
|
||||
// English subtitle is what she's here to learn.
|
||||
|
||||
import type { Pack } from '../types'
|
||||
|
||||
export const zh: Pack = {
|
||||
code: 'zh',
|
||||
nativeName: '中文',
|
||||
|
||||
app: {
|
||||
duplicateTitle: (title) => `${title} (副本)`,
|
||||
garden: '词汇花园',
|
||||
history: '历史',
|
||||
},
|
||||
|
||||
auth: {
|
||||
title: '请重新登录',
|
||||
titleEn: 'Please sign in again',
|
||||
bodyWithDraft: '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。',
|
||||
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
|
||||
bodyPlain: '登录状态过期了。你的文字都已经保存好了。',
|
||||
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
|
||||
signIn: '去登录 · Sign in',
|
||||
},
|
||||
|
||||
companion: {
|
||||
choose: '选个小伙伴 · Choose a companion',
|
||||
|
||||
// Played when a suggestion is accepted or a milestone hits — pure warmth.
|
||||
encouragements: [
|
||||
{ native: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||
{ native: '你写得越来越好了 ✨', en: "You're getting better and better." },
|
||||
{ native: '我很喜欢这个改法 💕', en: 'I really like that change.' },
|
||||
{ native: '继续保持,加油!', en: 'Keep going — you’ve got this!' },
|
||||
{ native: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ native: '这个词用得真好 🌷', en: 'That’s such a good word choice.' },
|
||||
{ native: '哇,这一段读起来真舒服 ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ native: '看你越写越有信心,真好 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ native: '一点点进步,都是了不起的进步 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ native: '今天的你,文字闪闪发光 ✨', en: 'Your words are sparkling today.' },
|
||||
],
|
||||
|
||||
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||
// checker finds nothing concrete to point at in her current text.
|
||||
tips: [
|
||||
{ native: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ native: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||
{ native: '过去的事情用过去式:go → went。', en: 'For the past, use past tense: go → went.' },
|
||||
{ native: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
|
||||
{ native: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
|
||||
{ native: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||
{ native: '复数别忘了加 s:two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||
],
|
||||
|
||||
// Shown after a long stretch of continuous writing.
|
||||
breaks: [
|
||||
{ native: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||
{ native: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
|
||||
{ native: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
],
|
||||
|
||||
// Shown when she's still writing late at night (≥11pm). Caring, a little
|
||||
// playful — the kitten is always asleep, so "you should be too" lands as a
|
||||
// gag, never a scold. The native line stays gentle; English carries the wink.
|
||||
bedtime: [
|
||||
{ native: '你的床在想你了哦 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ native: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ native: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ native: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// A few old Chinese proverbs on sleep (the native line is a faithful
|
||||
// rendering of the English sense — the verified classical 原文 isn't
|
||||
// recoverable; swap in the exact source text if you have it). They read a
|
||||
// touch wittier/wiser than the gentle lines above, which suits a
|
||||
// late-night nudge.
|
||||
{ native: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||
{ native: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||
{ native: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||
],
|
||||
|
||||
greeting: { native: '嗨~我在这儿陪你写作哦 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
|
||||
welcomeBack: { native: '欢迎回来 ✨ 我们继续吧!', en: 'Welcome back ✨ let’s keep going!' },
|
||||
|
||||
errors: [
|
||||
{ native: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ native: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ native: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
],
|
||||
|
||||
milestone: (words: number) => ({
|
||||
native: `哇!已经 ${words} 个词了,太厉害了 🎉`,
|
||||
en: `Wow — ${words} words already! Amazing. 🎉`,
|
||||
}),
|
||||
|
||||
// Keyed by the companion ids in Companion/companions.ts.
|
||||
names: {
|
||||
cat: '瞌睡猫',
|
||||
dog: '开心狗',
|
||||
'wiggle-dog': '摇尾狗',
|
||||
butterfly: '蝴蝶',
|
||||
parrot: '鹦鹉',
|
||||
},
|
||||
},
|
||||
|
||||
prose: {
|
||||
longSentence: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||
commaSplice: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||
vagueThis: (word) => `“${word}” 指代不太清楚,最好点明它指的是什么(比如 “${word} idea / change…”)。`,
|
||||
oxfordComma: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||
transitionComma: (word) => `开头的过渡词后面加个逗号:“${word}, …”。`,
|
||||
capitalizeSentence: '每个句子的开头,用大写字母开始吧。',
|
||||
repeatedWord: (word) => `“${word}” 好像写了两遍,检查一下哦。`,
|
||||
capitalizeI: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||
spaceBeforePunct: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||
spaceAfterPunct: '逗号、句号后面要空一格,再接下一个词。',
|
||||
articleAn: (word) => `元音开头的词前用 “an”:“an ${word}”。`,
|
||||
articleA: (word) => `辅音开头的词前用 “a”:“a ${word}”。`,
|
||||
uncountable: (word, singular) => `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||
capitalizeProper: (fixed) => `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||
thirdPersonS: (subject, verb) => `主语是 he/she/it 时,动词要加 -s:“${subject} ${verb}”。`,
|
||||
pluralAfter: (determiner, noun) => `“${determiner}” 后面的名词要用复数:“${determiner} ${noun}s”。`,
|
||||
doubleDeterminer: (first, second) => `“${first} ${second}” 用了两个限定词,留一个就好(比如去掉 “${first}”)。`,
|
||||
thereArePlural: (noun) => `后面是复数时用 “there are”:“there are ${noun}…”。`,
|
||||
itsOwn: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||
itsIs: (rest) => `这里应该是 “it’s ${rest}”(it is),“its” 是“它的”。`,
|
||||
thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`,
|
||||
},
|
||||
|
||||
docs: {
|
||||
sortRecent: '最近 · Recent',
|
||||
sortTitle: '标题 · Title',
|
||||
sortLongest: '字数 · Longest',
|
||||
backUpAll: '备份 · Back up all:',
|
||||
signOut: '退出 · Sign out',
|
||||
duplicate: '副本 · Duplicate',
|
||||
searchPlaceholder: '搜索 · Search',
|
||||
searching: '查找中… · Searching…',
|
||||
noMatches: '没有找到 · No matches',
|
||||
tags: '标签 · Tags',
|
||||
newTagPlaceholder: '新标签 · New tag',
|
||||
},
|
||||
|
||||
editor: {
|
||||
askPlaceholder: 'Ask why… / 问为什么…',
|
||||
findPlaceholder: '查找 · Find',
|
||||
findNone: '无 · 0',
|
||||
matchCase: 'Match case · 区分大小写',
|
||||
close: 'Close · 关闭',
|
||||
replacePlaceholder: '替换为 · Replace',
|
||||
replace: '替换',
|
||||
replaceAll: '全部',
|
||||
spelling: '拼写 · Spelling',
|
||||
noSuggestions: '没有建议 · No suggestions',
|
||||
addToDictionary: '添加到词典 · Add to dictionary',
|
||||
readSelection: '朗读所选 · Read selection aloud',
|
||||
rewrite: '改写 · Rewrite',
|
||||
rewriting: '改写中… · Rewriting…',
|
||||
rewriteFailed: '改写失败,请再试一次 · Couldn’t rewrite — try again',
|
||||
cancel: '取消 · Cancel',
|
||||
retry: '重试 · Retry',
|
||||
useThis: '用这个 · Use this',
|
||||
word: '词语 · Word',
|
||||
inGarden: '已在词汇花园 · In your garden (tap to remove)',
|
||||
saveToGarden: '加入词汇花园 · Save to garden',
|
||||
readAloud: '朗读 · Read aloud',
|
||||
lookingUp: '查找中… · Looking up…',
|
||||
definition: '释义 · Definition',
|
||||
synonyms: '近义词 · Synonyms',
|
||||
tapToSwap: '点击替换 · tap to swap',
|
||||
nothingFound: '没有找到这个词 · Nothing found for this word',
|
||||
},
|
||||
|
||||
styles: {
|
||||
natural: { native: '更自然', en: 'Natural' },
|
||||
academic: { native: '学术', en: 'Academic' },
|
||||
professional: { native: '专业', en: 'Professional' },
|
||||
casual: { native: '轻松', en: 'Casual' },
|
||||
humorous: { native: '幽默', en: 'Humorous' },
|
||||
creative: { native: '创意', en: 'Creative' },
|
||||
persuasive: { native: '说服', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
tones: {
|
||||
general: { native: '通用', en: 'General' },
|
||||
academic: { native: '学术', en: 'Academic' },
|
||||
professional: { native: '专业', en: 'Professional' },
|
||||
casual: { native: '轻松', en: 'Casual' },
|
||||
humorous: { native: '幽默', en: 'Humorous' },
|
||||
creative: { native: '创意', en: 'Creative' },
|
||||
persuasive: { native: '说服', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
exports: {
|
||||
label: '导出',
|
||||
print: '打印 / PDF',
|
||||
formats: {
|
||||
md: { native: 'Markdown', en: 'Markdown (.md)' },
|
||||
docx: { native: 'Word 文档', en: 'Word (.docx)' },
|
||||
html: { native: '网页', en: 'Web page (.html)' },
|
||||
txt: { native: '纯文本', en: 'Plain text (.txt)' },
|
||||
},
|
||||
},
|
||||
|
||||
garden: {
|
||||
title: '词汇花园 · Vocabulary Garden',
|
||||
titleWithFlower: '🌷 词汇花园 · Vocabulary Garden',
|
||||
reviewing: '复习中 · Reviewing — recall, then grade yourself',
|
||||
subtitle: 'Words you looked up, blooming as you learn them',
|
||||
reviewDue: (n) => `复习 ${n} 个词 · Review ${n} due 🌸`,
|
||||
emptyLead: '你的花园还空着。',
|
||||
emptyHint: '右键点一个英文单词查它的意思——它就会在这里发芽。',
|
||||
due: '待复习 · due',
|
||||
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
|
||||
readAloud: '🔊 朗读',
|
||||
source: '📄 出处 · Source',
|
||||
remove: '🗑 移除',
|
||||
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||
end: '结束 · End',
|
||||
promptProduction: '这个中文意思的英文单词是?· Which English word?',
|
||||
promptRecognition: '这个词什么意思?· What does this mean?',
|
||||
showAnswer: '翻看答案 · Show answer',
|
||||
gradeAgain: { native: '再来', en: 'Again' },
|
||||
gradeGood: { native: '记得', en: 'Good' },
|
||||
gradeEasy: { native: '太简单', en: 'Easy' },
|
||||
},
|
||||
|
||||
history: {
|
||||
title: '历史 · History',
|
||||
kinds: {
|
||||
manual: { native: '保存点', en: 'Saved point' },
|
||||
auto: { native: '自动', en: 'Auto' },
|
||||
pre_restore: { native: '恢复前', en: 'Before restore' },
|
||||
},
|
||||
justNow: 'just now · 刚刚',
|
||||
minutesAgo: (n) => `${n} min ago · ${n} 分钟前`,
|
||||
hoursAgo: (n) => `${n} hr ago · ${n} 小时前`,
|
||||
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · ${n} 天前`,
|
||||
preview: '预览 · Preview',
|
||||
restoring: 'Restoring…',
|
||||
restoreThis: '恢复这个版本 · Restore this version',
|
||||
passport: '📜 写作证明 · Writing passport',
|
||||
keepFullHistory: '保留完整历史 · Keep full history',
|
||||
},
|
||||
|
||||
status: {
|
||||
savedLocally: '已保存在本机 · Kept on this device',
|
||||
helperRestingNative: '小助手在休息',
|
||||
helperRestingEn: "· Petal's helper is resting · 文字已保存",
|
||||
soundsOn: '声音开 · Sounds on',
|
||||
soundsOff: '声音关 · Sounds off',
|
||||
petalsOn: '花瓣开 · Petals on',
|
||||
petalsOff: '花瓣关 · Petals off',
|
||||
statsTitle: '写作统计 · Writing stats',
|
||||
stats: {
|
||||
words: { native: '字数', en: 'Words' },
|
||||
characters: { native: '字符', en: 'Characters' },
|
||||
sentences: { native: '句子', en: 'Sentences' },
|
||||
paragraphs: { native: '段落', en: 'Paragraphs' },
|
||||
pages: { native: '页数', en: 'Pages' },
|
||||
readingTime: { native: '阅读时间', en: 'Reading time' },
|
||||
avgWordLength: { native: '平均词长', en: 'Avg word length' },
|
||||
variety: { native: '词汇丰富度', en: 'Word variety' },
|
||||
readability: { native: '阅读难度', en: 'Reading level' },
|
||||
},
|
||||
readability: {
|
||||
easy: { native: '简单', en: 'Easy' },
|
||||
standard: { native: '标准', en: 'Standard' },
|
||||
fairlyHard: { native: '偏难', en: 'Fairly hard' },
|
||||
advanced: { native: '较难', en: 'Advanced' },
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
untitledHeading: '(无标题)',
|
||||
outline: '大纲 · Outline',
|
||||
outlineHint: '用 H1/H2/H3 添加标题,这里就会出现导航。',
|
||||
},
|
||||
|
||||
update: {
|
||||
available: '有新版本啦',
|
||||
refresh: '刷新 · Refresh',
|
||||
dismiss: '稍后再说 · Dismiss',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// The shape of a langpack.
|
||||
//
|
||||
// Petal gives every writer one (English + X) pair: she reads and writes in
|
||||
// English, and Petal explains itself in both. Until now X was hardcoded as
|
||||
// Mandarin — every label in the app carried its own `中文 · English` string
|
||||
// literal. A pack is that copy lifted out and keyed by X, so adding pt-PT is
|
||||
// writing one file rather than re-editing twenty-nine.
|
||||
//
|
||||
// Two conventions worth keeping when a new pack is written:
|
||||
//
|
||||
// * `native` is the writer's own language, `en` is English. Where a label
|
||||
// shows both in one line the pack holds the *whole* rendered string
|
||||
// (`'搜索 · Search'`), not the two halves — order and separator are a
|
||||
// typographic choice each language gets to make.
|
||||
// * Anything with a value in it is a function, not a template assembled at
|
||||
// the call site. Word order is not universal, and a pack author must be
|
||||
// able to move the number.
|
||||
|
||||
// PairLang is the X in the (English + X) pair. It matches `users.pair_lang`.
|
||||
export type PairLang = 'zh' | 'pt-PT' | 'fr' | 'es'
|
||||
|
||||
// Line is a two-line piece of copy: the writer's language on top, English
|
||||
// underneath. The companion bubble and the small pill labels render both.
|
||||
export interface Line {
|
||||
native: string
|
||||
en: string
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
// code identifies the pack; `nativeName` is how the language names itself,
|
||||
// for anywhere Petal has to say which pair this is.
|
||||
code: PairLang
|
||||
nativeName: string
|
||||
|
||||
app: {
|
||||
// A duplicated document's title. A function, not a suffix: where the marker
|
||||
// goes is the pack's business.
|
||||
duplicateTitle: (title: string) => string
|
||||
garden: string
|
||||
history: string
|
||||
}
|
||||
|
||||
auth: {
|
||||
title: string
|
||||
titleEn: string
|
||||
bodyWithDraft: string
|
||||
bodyWithDraftEn: string
|
||||
bodyPlain: string
|
||||
bodyPlainEn: string
|
||||
signIn: string
|
||||
}
|
||||
|
||||
companion: {
|
||||
choose: string
|
||||
encouragements: Line[]
|
||||
tips: Line[]
|
||||
breaks: Line[]
|
||||
bedtime: Line[]
|
||||
greeting: Line
|
||||
welcomeBack: Line
|
||||
errors: Line[]
|
||||
milestone: (words: number) => Line
|
||||
// Mascot names, keyed by the companion ids in companions.ts.
|
||||
names: Record<string, string>
|
||||
}
|
||||
|
||||
// Rule-based prose notes (see Companion/prose.ts). Detection is English
|
||||
// grammar and stays in the checker; only the explanation belongs to the pair.
|
||||
prose: {
|
||||
longSentence: string
|
||||
commaSplice: string
|
||||
vagueThis: (word: string) => string
|
||||
oxfordComma: string
|
||||
transitionComma: (word: string) => string
|
||||
capitalizeSentence: string
|
||||
repeatedWord: (word: string) => string
|
||||
capitalizeI: string
|
||||
spaceBeforePunct: string
|
||||
spaceAfterPunct: string
|
||||
articleAn: (word: string) => string
|
||||
articleA: (word: string) => string
|
||||
uncountable: (word: string, singular: string) => string
|
||||
capitalizeProper: (fixed: string) => string
|
||||
thirdPersonS: (subject: string, verb: string) => string
|
||||
pluralAfter: (determiner: string, noun: string) => string
|
||||
doubleDeterminer: (first: string, second: string) => string
|
||||
thereArePlural: (noun: string) => string
|
||||
itsOwn: string
|
||||
itsIs: (rest: string) => string
|
||||
thanNotThen: (word: string) => string
|
||||
}
|
||||
|
||||
docs: {
|
||||
sortRecent: string
|
||||
sortTitle: string
|
||||
sortLongest: string
|
||||
backUpAll: string
|
||||
signOut: string
|
||||
duplicate: string
|
||||
searchPlaceholder: string
|
||||
searching: string
|
||||
noMatches: string
|
||||
tags: string
|
||||
newTagPlaceholder: string
|
||||
}
|
||||
|
||||
editor: {
|
||||
askPlaceholder: string
|
||||
findPlaceholder: string
|
||||
findNone: string
|
||||
matchCase: string
|
||||
close: string
|
||||
replacePlaceholder: string
|
||||
replace: string
|
||||
replaceAll: string
|
||||
spelling: string
|
||||
noSuggestions: string
|
||||
addToDictionary: string
|
||||
readSelection: string
|
||||
// The rewrite preview.
|
||||
rewrite: string
|
||||
rewriting: string
|
||||
rewriteFailed: string
|
||||
cancel: string
|
||||
retry: string
|
||||
useThis: string
|
||||
// Word card.
|
||||
word: string
|
||||
inGarden: string
|
||||
saveToGarden: string
|
||||
readAloud: string
|
||||
lookingUp: string
|
||||
definition: string
|
||||
synonyms: string
|
||||
tapToSwap: string
|
||||
nothingFound: string
|
||||
}
|
||||
|
||||
// Rewrite styles and document tones share a vocabulary; both are Line-labelled
|
||||
// pills, keyed by the style/tone value.
|
||||
styles: Record<string, Line>
|
||||
tones: Record<string, Line>
|
||||
|
||||
exports: {
|
||||
label: string
|
||||
print: string
|
||||
formats: Record<string, Line> // keyed by export format
|
||||
}
|
||||
|
||||
garden: {
|
||||
title: string
|
||||
titleWithFlower: string
|
||||
reviewing: string
|
||||
subtitle: string
|
||||
reviewDue: (n: number) => string
|
||||
emptyLead: string
|
||||
emptyHint: string
|
||||
due: string
|
||||
seen: (reps: number, intervalDays: number) => string
|
||||
readAloud: string
|
||||
source: string
|
||||
remove: string
|
||||
growing: (n: number) => string
|
||||
end: string
|
||||
promptProduction: string
|
||||
promptRecognition: string
|
||||
showAnswer: string
|
||||
gradeAgain: Line
|
||||
gradeGood: Line
|
||||
gradeEasy: Line
|
||||
}
|
||||
|
||||
history: {
|
||||
title: string
|
||||
kinds: Record<string, Line> // manual | auto | pre_restore
|
||||
justNow: string
|
||||
minutesAgo: (n: number) => string
|
||||
hoursAgo: (n: number) => string
|
||||
daysAgo: (n: number) => string
|
||||
preview: string
|
||||
restoring: string
|
||||
restoreThis: string
|
||||
passport: string
|
||||
keepFullHistory: string
|
||||
}
|
||||
|
||||
status: {
|
||||
savedLocally: string
|
||||
helperRestingNative: string
|
||||
helperRestingEn: string
|
||||
soundsOn: string
|
||||
soundsOff: string
|
||||
petalsOn: string
|
||||
petalsOff: string
|
||||
statsTitle: string
|
||||
stats: Record<string, Line> // stat labels, keyed by the stat id
|
||||
readability: {
|
||||
easy: Line
|
||||
standard: Line
|
||||
fairlyHard: Line
|
||||
advanced: Line
|
||||
}
|
||||
}
|
||||
|
||||
toolbar: {
|
||||
untitledHeading: string
|
||||
outline: string
|
||||
outlineHint: string
|
||||
}
|
||||
|
||||
update: {
|
||||
available: string
|
||||
refresh: string
|
||||
dismiss: string
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user