feat(ui): add i18n scaffolding
Adds a minimal i18n provider with locale preference support and migrates folder selection copy to message keys.
This commit is contained in:
@@ -13,6 +13,7 @@ const PreferencesSchema = z.object({
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -24,6 +25,7 @@ interface FolderSelectionViewProps {
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
||||
const { t } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
@@ -181,10 +183,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return "just now"
|
||||
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||
return t("time.relative.justNow")
|
||||
}
|
||||
|
||||
function handleFolderSelect(path: string) {
|
||||
@@ -203,7 +205,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (nativeDialogsAvailable) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: "Select Workspace",
|
||||
title: t("folderSelection.dialog.title"),
|
||||
defaultPath: fallbackPath,
|
||||
})
|
||||
if (selected) {
|
||||
@@ -266,7 +268,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
@@ -275,8 +277,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad GitHub"
|
||||
title="CodeNomad GitHub"
|
||||
aria-label={t("folderSelection.links.github")}
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
@@ -289,8 +291,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label="CodeNomad GitHub Stars"
|
||||
title="CodeNomad GitHub Stars"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
@@ -306,8 +308,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad Discord"
|
||||
title="CodeNomad Discord"
|
||||
aria-label={t("folderSelection.links.discord")}
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
@@ -318,7 +320,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||
@@ -332,16 +334,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -393,7 +400,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
@@ -411,8 +418,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
@@ -424,7 +431,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
@@ -435,7 +446,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
@@ -457,20 +468,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
<span>{t("folderSelection.hints.navigate")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
<span>{t("folderSelection.hints.select")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
<span>{t("folderSelection.hints.remove")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
<span>{t("folderSelection.hints.browse")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,8 +491,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="folder-loading-overlay">
|
||||
<div class="folder-loading-indicator">
|
||||
<div class="spinner" />
|
||||
<p class="folder-loading-text">Starting instance…</p>
|
||||
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
|
||||
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
|
||||
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -497,8 +508,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title="Select Workspace"
|
||||
description="Select workspace to start coding."
|
||||
title={t("folderSelection.dialog.title")}
|
||||
description={t("folderSelection.dialog.description")}
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
104
packages/ui/src/lib/i18n/index.tsx
Normal file
104
packages/ui/src/lib/i18n/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { ParentComponent } from "solid-js"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { enMessages } from "./messages/en"
|
||||
|
||||
type Messages = Record<string, string>
|
||||
|
||||
export type Locale = "en"
|
||||
|
||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const
|
||||
|
||||
const messagesByLocale: Record<Locale, Messages> = {
|
||||
en: enMessages,
|
||||
}
|
||||
|
||||
function normalizeLocaleTag(value: string): string {
|
||||
return value.trim().replace(/_/g, "-")
|
||||
}
|
||||
|
||||
function matchSupportedLocale(value: string | undefined): Locale | null {
|
||||
if (!value) return null
|
||||
|
||||
const normalized = normalizeLocaleTag(value)
|
||||
const lower = normalized.toLowerCase()
|
||||
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||
const exact = supportedLower.get(lower)
|
||||
if (exact) return exact
|
||||
|
||||
const base = lower.split("-")[0]
|
||||
if (!base) return null
|
||||
const baseMatch = supportedLower.get(base)
|
||||
return baseMatch ?? null
|
||||
}
|
||||
|
||||
function detectNavigatorLocale(): Locale | null {
|
||||
if (typeof navigator === "undefined") return null
|
||||
|
||||
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? navigator.languages
|
||||
: navigator.language
|
||||
? [navigator.language]
|
||||
: []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const match = matchSupportedLocale(candidate)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, unknown>): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const value = params[key]
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export interface I18nContextValue {
|
||||
locale: () => Locale
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>()
|
||||
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const { preferences } = useConfig()
|
||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
||||
|
||||
onMount(() => {
|
||||
const detected = detectNavigatorLocale()
|
||||
if (detected) setDetectedLocale(detected)
|
||||
})
|
||||
|
||||
const locale = createMemo<Locale>(() => {
|
||||
const configured = matchSupportedLocale(preferences().locale)
|
||||
return configured ?? detectedLocale() ?? "en"
|
||||
})
|
||||
|
||||
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
||||
|
||||
function t(key: string, params?: Record<string, unknown>): string {
|
||||
const current = messages()[key]
|
||||
const fallback = enMessages[key as keyof typeof enMessages]
|
||||
const template = current ?? fallback ?? key
|
||||
return interpolate(template, params)
|
||||
}
|
||||
|
||||
const value: I18nContextValue = {
|
||||
locale,
|
||||
t,
|
||||
}
|
||||
|
||||
return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
39
packages/ui/src/lib/i18n/messages/en.ts
Normal file
39
packages/ui/src/lib/i18n/messages/en.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const enMessages = {
|
||||
"folderSelection.logoAlt": "CodeNomad logo",
|
||||
"folderSelection.tagline": "Select a folder to start coding with AI",
|
||||
|
||||
"folderSelection.links.github": "CodeNomad GitHub",
|
||||
"folderSelection.links.githubStars": "CodeNomad GitHub Stars",
|
||||
"folderSelection.links.discord": "CodeNomad Discord",
|
||||
|
||||
"folderSelection.empty.title": "No Recent Folders",
|
||||
"folderSelection.empty.description": "Browse for a folder to get started",
|
||||
|
||||
"folderSelection.recent.title": "Recent Folders",
|
||||
"folderSelection.recent.subtitle.one": "{count} folder available",
|
||||
"folderSelection.recent.subtitle.other": "{count} folders available",
|
||||
"folderSelection.recent.remove": "Remove from recent",
|
||||
|
||||
"folderSelection.browse.title": "Browse for Folder",
|
||||
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||
"folderSelection.browse.button": "Browse Folders",
|
||||
"folderSelection.browse.buttonOpening": "Opening...",
|
||||
|
||||
"folderSelection.advancedSettings": "Advanced Settings",
|
||||
|
||||
"folderSelection.hints.navigate": "Navigate",
|
||||
"folderSelection.hints.select": "Select",
|
||||
"folderSelection.hints.remove": "Remove",
|
||||
"folderSelection.hints.browse": "Browse",
|
||||
|
||||
"folderSelection.loading.title": "Starting instance...",
|
||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||
|
||||
"folderSelection.dialog.title": "Select Workspace",
|
||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||
|
||||
"time.relative.justNow": "just now",
|
||||
"time.relative.daysAgoShort": "{count}d ago",
|
||||
"time.relative.hoursAgoShort": "{count}h ago",
|
||||
"time.relative.minutesAgoShort": "{count}m ago",
|
||||
} as const
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { I18nProvider } from "./lib/i18n"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -22,9 +23,11 @@ render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<InstanceConfigProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Preferences {
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
lastUsedBinary?: string
|
||||
locale?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
modelThinkingSelections: Record<string, string>
|
||||
@@ -114,6 +115,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
locale: sanitized.locale ?? defaultPreferences.locale,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
modelThinkingSelections,
|
||||
@@ -578,4 +580,3 @@ export {
|
||||
recordWorkspaceLaunch,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user