Introduce a lightweight React context-based i18n layer for the desktop app and translate the UI into Simplified Chinese. - New apps/desktop/src/i18n module: typed Translations interface, en + zh locale tables, I18nProvider/useI18n, localStorage-persisted locale (defaults to English), and language endonym metadata for the picker. - Wire I18nProvider at the app root in main.tsx. - Refactor 24 desktop screens/components to read strings from the `t` object instead of hard-coded English. - Add a unit test for the i18n context.
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
|
|
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
|
|
import { triggerHaptic } from '@/lib/haptics'
|
|
import { Check, Palette } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
|
import { useTheme } from '@/themes/context'
|
|
import { BUILTIN_THEMES } from '@/themes/presets'
|
|
|
|
import { MODE_OPTIONS } from './constants'
|
|
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
|
|
|
function ThemePreview({ name }: { name: string }) {
|
|
const t = BUILTIN_THEMES[name]
|
|
|
|
if (!t) {
|
|
return null
|
|
}
|
|
|
|
const c = t.colors
|
|
|
|
return (
|
|
<div
|
|
className="h-20 overflow-hidden rounded-xl border shadow-xs"
|
|
style={{ backgroundColor: c.background, borderColor: c.border }}
|
|
>
|
|
<div className="flex h-full">
|
|
<div
|
|
className="w-12 border-r"
|
|
style={{
|
|
backgroundColor: c.sidebarBackground ?? c.muted,
|
|
borderColor: c.sidebarBorder ?? c.border
|
|
}}
|
|
/>
|
|
<div className="flex flex-1 flex-col gap-2 p-3">
|
|
<div className="h-2.5 w-16 rounded-full" style={{ backgroundColor: c.foreground }} />
|
|
<div className="h-2 w-24 rounded-full" style={{ backgroundColor: c.mutedForeground }} />
|
|
<div className="mt-auto flex justify-end">
|
|
<div
|
|
className="h-5 w-16 rounded-full border"
|
|
style={{
|
|
backgroundColor: c.userBubble ?? c.muted,
|
|
borderColor: c.userBubbleBorder ?? c.border
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function AppearanceSettings() {
|
|
const { t, locale, setLocale } = useI18n()
|
|
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
|
const toolViewMode = useStore($toolViewMode)
|
|
const activeTheme = availableThemes.find(theme => theme.name === themeName)
|
|
const a = t.settings.appearance
|
|
const locales = Object.keys(LOCALE_META) as Locale[]
|
|
|
|
return (
|
|
<SettingsContent>
|
|
<div className="space-y-5">
|
|
<div>
|
|
<SectionHeading icon={Palette} title={a.title} />
|
|
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{a.intro}
|
|
</p>
|
|
</div>
|
|
|
|
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium">{t.language.label}</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
|
|
</div>
|
|
<Pill>{LOCALE_META[locale].name}</Pill>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
{locales.map(code => {
|
|
const active = locale === code
|
|
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
|
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
|
)}
|
|
key={code}
|
|
onClick={() => {
|
|
triggerHaptic('crisp')
|
|
setLocale(code)
|
|
}}
|
|
type="button"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
|
|
{LOCALE_META[code].name}
|
|
</div>
|
|
{active && (
|
|
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
<Check className="size-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
|
|
{code}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium">{a.colorMode}</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
|
|
</div>
|
|
<Pill>{t.settings.modeOptions[mode].label}</Pill>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
|
|
const active = mode === id
|
|
const copy = t.settings.modeOptions[id]
|
|
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
|
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
|
)}
|
|
key={id}
|
|
onClick={() => {
|
|
triggerHaptic('crisp')
|
|
setMode(id)
|
|
}}
|
|
type="button"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
{active && (
|
|
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
<Check className="size-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{copy.description}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium">{a.toolViewTitle}</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
|
|
</div>
|
|
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{(
|
|
[
|
|
{ id: 'product', label: a.product, description: a.productDesc },
|
|
{ id: 'technical', label: a.technical, description: a.technicalDesc }
|
|
] as const
|
|
).map(option => {
|
|
const active = toolViewMode === option.id
|
|
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
|
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
|
)}
|
|
key={option.id}
|
|
onClick={() => {
|
|
triggerHaptic('selection')
|
|
setToolViewMode(option.id)
|
|
}}
|
|
type="button"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
|
|
{active && (
|
|
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
<Check className="size-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{option.description}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium">{a.themeTitle}</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
|
|
</div>
|
|
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{availableThemes.map(theme => {
|
|
const active = themeName === theme.name
|
|
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
|
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
|
)}
|
|
key={theme.name}
|
|
onClick={() => {
|
|
triggerHaptic('crisp')
|
|
setTheme(theme.name)
|
|
}}
|
|
type="button"
|
|
>
|
|
<ThemePreview name={theme.name} />
|
|
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
|
{theme.label}
|
|
</div>
|
|
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{theme.description}
|
|
</div>
|
|
</div>
|
|
{active && (
|
|
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
<Check className="size-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</SettingsContent>
|
|
)
|
|
}
|