diff --git a/app.vue b/app.vue index 112f8ef..9da0a7f 100644 --- a/app.vue +++ b/app.vue @@ -6,6 +6,7 @@ diff --git a/components/AppSidebar.vue b/components/AppSidebar.vue index 2262923..d06cb41 100644 --- a/components/AppSidebar.vue +++ b/components/AppSidebar.vue @@ -17,15 +17,15 @@ v-if="isPrereleaseBuild" class="mb-2 rounded-md bg-teal-500/10 border border-teal-500/20 px-3 py-1.5 text-center text-xs font-medium text-teal-400" > - PRE-RELEASE + {{ $t('sidebar.pre_release') }}
- {{ networkLabel }} + {{ $t(networkLabelKey) }}
@@ -42,9 +42,9 @@ v-if="updaterStore.isPrerelease" class="text-[10px] font-bold uppercase tracking-wider opacity-70" > - Pre-Release + {{ $t('sidebar.update_pre_release_tag') }} -
Update Available
+
{{ $t('sidebar.update_available') }}
v{{ updaterStore.version }}
@@ -60,7 +60,7 @@ : 'text-autonomi-muted hover:bg-autonomi-border/50 hover:text-autonomi-text'" > {{ item.icon }} - {{ item.label }} + {{ $t(item.labelKey) }} @@ -74,7 +74,7 @@ : 'text-autonomi-muted hover:bg-autonomi-border/50 hover:text-autonomi-text'" > - Settings + {{ $t('nav.settings') }} @@ -109,18 +109,18 @@ const isPrereleaseBuild = computed(() => /-(?:rc|beta|alpha)\./.test(currentVersion.value ?? ''), ) -const networkLabel = computed(() => { +const networkLabelKey = computed(() => { switch (settingsStore.devnetChainId) { - case arbitrumSepolia.id: return 'SEPOLIA TESTNET' - case ANVIL_CHAIN_ID: return 'DEVNET' + case arbitrumSepolia.id: return 'sidebar.network_sepolia' + case ANVIL_CHAIN_ID: return 'sidebar.network_devnet' default: return null // mainnet or unset — no badge } }) const mainNav = computed(() => [ - { path: '/', label: 'Nodes', icon: '⬡' }, - { path: '/files', label: 'Files', icon: '◫' }, - { path: '/wallet', label: 'Wallet', icon: '◎' }, + { path: '/', labelKey: 'nav.nodes', icon: '⬡' }, + { path: '/files', labelKey: 'nav.files', icon: '◫' }, + { path: '/wallet', labelKey: 'nav.wallet', icon: '◎' }, ]) function isActive(path: string) { diff --git a/components/StatusBadge.vue b/components/StatusBadge.vue index 302cde6..507fc7a 100644 --- a/components/StatusBadge.vue +++ b/components/StatusBadge.vue @@ -15,35 +15,43 @@ diff --git a/plugins/i18n.client.ts b/plugins/i18n.client.ts new file mode 100644 index 0000000..f8825d8 --- /dev/null +++ b/plugins/i18n.client.ts @@ -0,0 +1,27 @@ +import { createI18n } from 'vue-i18n' +import en from '~/locales/en.json' +import ja from '~/locales/ja.json' + +/** + * Exported so non-component code (Pinia options-API stores, plain utility + * modules) can call `i18n.global.t(...)` without a setup context — `useI18n()` + * only works inside Vue setup, but options-API store actions don't qualify. + */ +export const i18n = createI18n({ + legacy: false, + locale: 'en', + fallbackLocale: 'en', + messages: { en, ja }, + missingWarn: true, + fallbackWarn: false, +}) + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.use(i18n) + + // Dev-only handle for manual locale-swap testing from DevTools console. + // Remove when the Settings picker lands. + if (import.meta.dev && typeof window !== 'undefined') { + ;(window as unknown as { __i18n: typeof i18n }).__i18n = i18n + } +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae5adc7..718ec17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -863,6 +863,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-updater", "tokio", @@ -3351,6 +3352,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -4745,6 +4756,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -4892,6 +4915,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4916,6 +4960,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4986,8 +5062,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -5114,6 +5209,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -6711,7 +6822,7 @@ dependencies = [ "keyring", "libc", "lru-slab", - "nix", + "nix 0.29.0", "once_cell", "parking_lot", "pin-project-lite", @@ -7564,6 +7675,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sysinfo" version = "0.32.1" @@ -7889,6 +8009,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8eb9dc1..35b495c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-os = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d6a7027..8a2cf4f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "dialog:allow-ask", "opener:default", "opener:allow-reveal-item-in-dir", + "os:default", + "os:allow-locale", "process:default", "updater:default", "updater:allow-check", diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4755cc8..8c99a2f 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -35,6 +35,11 @@ pub struct AppConfig { /// init from this flag, so toggling requires an app restart to take effect. #[serde(default)] pub prerelease_channel: bool, + /// User-chosen UI locale (e.g. "en", "ja"). `None` means "follow system": + /// the frontend reads the OS locale via tauri-plugin-os and falls back to + /// English if it doesn't match a shipped locale. + #[serde(default)] + pub i18n_locale: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -132,6 +137,7 @@ impl Default for AppConfig { theme_mode: default_theme_mode(), upload_concurrency: default_upload_concurrency(), prerelease_channel: false, + i18n_locale: None, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43e35f3..355281b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -743,6 +743,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .manage(AutonomiState::new()) diff --git a/stores/files.ts b/stores/files.ts index 9a0c5c9..13ddbfd 100644 --- a/stores/files.ts +++ b/stores/files.ts @@ -5,6 +5,12 @@ import { useToastStore } from './toasts' import { useSettingsStore } from './settings' import { payForQuotes, payForMerkleTree, formatNanoTokens, formatGasCost, type RawPayment, type SerializedPoolCommitment } from '~/utils/payment' import { indelibleApi } from '~/utils/indelible-api' +import { i18n } from '~/plugins/i18n.client' + +/** Module-scope translator. Options-API store actions can't call useI18n() — + * they run outside Vue's setup context — so we route through the global + * composer the plugin already configured. */ +const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {}) // ── Lightweight cost estimate (no chunks parked) ── // Matches ant_core::data::UploadCostEstimate. @@ -605,10 +611,10 @@ export const useFilesStore = defineStore('files', { stageTotal: undefined, }) await this.persistHistory() - toasts.add(`Upload complete: ${entry.name}`, 'info') + toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info') } catch (e: any) { this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) }) - toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error') + toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error') } return } @@ -691,7 +697,7 @@ export const useFilesStore = defineStore('files', { }) } catch (e: any) { this.updateEntry(id, { status: 'failed', error: `Payment failed: ${e.message}` }) - toasts.add(`Payment failed: ${e.message}`, 'error') + toasts.add(t('files.toast.payment_failed', { error: e.message }), 'error') return } } else { @@ -708,7 +714,7 @@ export const useFilesStore = defineStore('files', { this.updateEntry(id, { gas_cost: formatGasCost(payResult.gasSpent.toString()) }) } catch (e: any) { this.updateEntry(id, { status: 'failed', error: `Payment failed: ${e.message}` }) - toasts.add(`Payment failed: ${e.message}`, 'error') + toasts.add(t('files.toast.payment_failed', { error: e.message }), 'error') return } } @@ -737,10 +743,10 @@ export const useFilesStore = defineStore('files', { } await this.persistHistory() - toasts.add(`Upload complete: ${entry.name}`, 'info') + toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info') } catch (e: any) { this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) }) - toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error') + toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error') } }, @@ -775,10 +781,10 @@ export const useFilesStore = defineStore('files', { }) await this.persistHistory() - toasts.add(`Upload complete: ${entry.name}`, 'info') + toasts.add(t('files.toast.upload_complete', { name: entry.name }), 'info') } catch (e: any) { this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) }) - toasts.add(`Upload failed: ${entry.name} — ${e.message ?? e}`, 'error') + toasts.add(t('files.toast.upload_failed', { name: entry.name, error: e.message ?? e }), 'error') } }, @@ -837,7 +843,7 @@ export const useFilesStore = defineStore('files', { this.updateEntry(id, { data_map_json: json }) } catch (e: any) { this.updateEntry(id, { status: 'failed', error: `Failed to read datamap: ${e.message ?? e}` }) - toasts.add('Cannot download: datamap file missing or unreadable', 'error') + toasts.add(t('files.toast.datamap_missing'), 'error') return } } @@ -879,10 +885,10 @@ export const useFilesStore = defineStore('files', { stageDone: undefined, stageTotal: undefined, }) - toasts.add(`Download complete: ${entry.name}`, 'info') + toasts.add(t('files.toast.download_complete', { name: entry.name }), 'info') } catch (e: any) { this.updateEntry(id, { status: 'failed', error: e.message ?? String(e) }) - toasts.add(`Download failed: ${entry.name} — ${e.message ?? e}`, 'error') + toasts.add(t('files.toast.download_failed', { name: entry.name, error: e.message ?? e }), 'error') } }, @@ -923,7 +929,7 @@ export const useFilesStore = defineStore('files', { try { json = await invoke('read_datamap_file', { path: datamapPath }) } catch (e: any) { - toasts.add(`Could not read datamap: ${e.message ?? e}`, 'error') + toasts.add(t('files.toast.datamap_read_failed', { error: e.message ?? e }), 'error') return null } diff --git a/stores/nodes.ts b/stores/nodes.ts index 8da21e2..7287534 100644 --- a/stores/nodes.ts +++ b/stores/nodes.ts @@ -6,6 +6,10 @@ import { daemonApi, connectSSE, disconnectSSE, type NodeEvent } from '~/utils/da import type { NodeStatusSummary, ApiNodeStatus, DaemonStatus } from '~/utils/daemon-api' import { POLL_INTERVAL, DETAIL_POLL_INTERVAL } from '~/utils/constants' import type { UnlistenFn } from '@tauri-apps/api/event' +import { i18n } from '~/plugins/i18n.client' + +/** Module-scope translator — see stores/files.ts for the rationale. */ +const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {}) // Frontend node status — ant-core values + frontend-only states export type NodeStatus = ApiNodeStatus | 'adding' @@ -417,11 +421,11 @@ export const useNodesStore = defineStore('nodes', { this.nodes = this.nodes.filter(n => !placeholderIds.includes(n.id)) await this.fetchNodes() this.enrichNodeDetails() // fire-and-forget — populates data_dir/ports without waiting for the detail poll - toasts.add(`Added ${result.nodes_added.length} node(s)`, 'info') + toasts.add(t('nodes.toast.added', { count: result.nodes_added.length }), 'info') } catch (e: any) { // Remove placeholders on failure this.nodes = this.nodes.filter(n => !placeholderIds.includes(n.id)) - toasts.add(`Failed to add nodes: ${e.message}`, 'error') + toasts.add(t('nodes.toast.add_failed', { error: e.message }), 'error') } }, @@ -438,11 +442,11 @@ export const useNodesStore = defineStore('nodes', { node.pid = result.pid node.uptime_secs = 0 } - toasts.add(`Node ${id} started`, 'info') + toasts.add(t('nodes.toast.started', { id }), 'info') } catch (e: any) { const node = this.nodes.find(n => n.id === id) if (node) node.status = 'errored' - toasts.add(`Failed to start node ${id}: ${e.message}`, 'error') + toasts.add(t('nodes.toast.start_failed', { id, error: e.message }), 'error') } }, @@ -458,9 +462,9 @@ export const useNodesStore = defineStore('nodes', { node.pid = undefined node.uptime_secs = undefined } - toasts.add(`Node ${id} stopped`, 'info') + toasts.add(t('nodes.toast.stopped', { id }), 'info') } catch (e: any) { - toasts.add(`Failed to stop node ${id}: ${e.message}`, 'error') + toasts.add(t('nodes.toast.stop_failed', { id, error: e.message }), 'error') } }, @@ -470,9 +474,9 @@ export const useNodesStore = defineStore('nodes', { try { await daemonApi.removeNode(id) this.nodes = this.nodes.filter(n => n.id !== id) - toasts.add(`Node ${id} removed`, 'info') + toasts.add(t('nodes.toast.removed', { id }), 'info') } catch (e: any) { - toasts.add(`Failed to remove node ${id}: ${e.message}`, 'error') + toasts.add(t('nodes.toast.remove_failed', { id, error: e.message }), 'error') } }, @@ -480,7 +484,7 @@ export const useNodesStore = defineStore('nodes', { const toasts = useToastStore() const stoppedNodes = this.nodes.filter(n => n.status === 'stopped') if (stoppedNodes.length === 0) { - toasts.add('No stopped nodes to start', 'warning') + toasts.add(t('nodes.toast.no_stopped'), 'warning') return } @@ -495,17 +499,17 @@ export const useNodesStore = defineStore('nodes', { for (const f of result.failed) { const node = this.nodes.find(n => n.id === f.node_id) if (node) node.status = 'errored' - toasts.add(`Node ${f.node_id} failed: ${f.error}`, 'error') + toasts.add(t('nodes.toast.node_failed', { id: f.node_id, error: f.error }), 'error') } for (const id of result.already_running) { const node = this.nodes.find(n => n.id === id) if (node) node.status = 'running' } if (result.started.length > 0) { - toasts.add(`Started ${result.started.length} node(s)`, 'info') + toasts.add(t('nodes.toast.started_count', { count: result.started.length }), 'info') } } catch (e: any) { - toasts.add(`Failed to start all: ${e.message}`, 'error') + toasts.add(t('nodes.toast.start_all_failed', { error: e.message }), 'error') await this.fetchNodes() // refresh to get real state } }, @@ -514,7 +518,7 @@ export const useNodesStore = defineStore('nodes', { const toasts = useToastStore() const runningNodes = this.nodes.filter(n => n.status === 'running') if (runningNodes.length === 0) { - toasts.add('No running nodes to stop', 'warning') + toasts.add(t('nodes.toast.no_running'), 'warning') return } @@ -528,13 +532,13 @@ export const useNodesStore = defineStore('nodes', { for (const f of result.failed) { const node = this.nodes.find(n => n.id === f.node_id) if (node) node.status = 'errored' - toasts.add(`Node ${f.node_id} failed: ${f.error}`, 'error') + toasts.add(t('nodes.toast.node_failed', { id: f.node_id, error: f.error }), 'error') } if (result.stopped.length > 0) { - toasts.add(`Stopped ${result.stopped.length} node(s)`, 'info') + toasts.add(t('nodes.toast.stopped_count', { count: result.stopped.length }), 'info') } } catch (e: any) { - toasts.add(`Failed to stop all: ${e.message}`, 'error') + toasts.add(t('nodes.toast.stop_all_failed', { error: e.message }), 'error') await this.fetchNodes() } }, diff --git a/stores/settings.ts b/stores/settings.ts index 9076b6c..8ae5d4a 100644 --- a/stores/settings.ts +++ b/stores/settings.ts @@ -33,6 +33,7 @@ interface AppConfig { theme_mode: string upload_concurrency: number prerelease_channel: boolean + i18n_locale: string | null } /** Allowed range for upload_concurrency (see AppConfig in Rust). */ @@ -67,6 +68,8 @@ export const useSettingsStore = defineStore('settings', { /** Snapshot of `prereleaseChannel` at app boot — drives the "restart to * apply" hint banner. Set once during loadConfig. */ prereleaseChannelBootValue: false, + /** Persisted UI locale choice. `null` = follow the OS. */ + i18nLocale: null as string | null, loaded: false, // Devnet/testnet/direct-key mode (set by manifest or settings form). // devnetChainId picks the chain: @@ -99,6 +102,7 @@ export const useSettingsStore = defineStore('settings', { this.themeMode = config.theme_mode === 'light' ? 'light' : 'dark' this.uploadConcurrency = clampConcurrency(config.upload_concurrency) this.prereleaseChannel = config.prerelease_channel ?? false + this.i18nLocale = config.i18n_locale ?? null // Capture once on first load — represents the value the Rust side // resolved its updater endpoint URL from. Subsequent toggles diverge // from this until the next app restart. @@ -122,6 +126,7 @@ export const useSettingsStore = defineStore('settings', { theme_mode: this.themeMode, upload_concurrency: this.uploadConcurrency, prerelease_channel: this.prereleaseChannel, + i18n_locale: this.i18nLocale, } await invoke('save_config', { config }) } catch (e) { @@ -169,6 +174,12 @@ export const useSettingsStore = defineStore('settings', { await this.saveConfig() }, + /** Persist UI-locale choice. `null` means "follow the OS locale". */ + async setI18nLocale(locale: string | null) { + this.i18nLocale = locale + await this.saveConfig() + }, + async testIndelibleConnection(url: string, apiKey: string): Promise<{ ok: boolean; error?: string }> { try { const baseUrl = url.replace(/\/+$/, '').replace(/\/api\/v2.*$/, '') diff --git a/stores/updater.ts b/stores/updater.ts index baa64b1..9b1f9f0 100644 --- a/stores/updater.ts +++ b/stores/updater.ts @@ -2,6 +2,10 @@ import { defineStore } from 'pinia' import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { useToastStore } from './toasts' +import { i18n } from '~/plugins/i18n.client' + +/** Module-scope translator — see stores/files.ts for the rationale. */ +const t = (key: string, params?: Record) => i18n.global.t(key, params ?? {}) export interface CheckResult { ok: boolean @@ -119,10 +123,7 @@ export const useUpdaterStore = defineStore('updater', { this._restartFailedUnlisten = await listen('update-restart-failed', (event) => { const v = event.payload.version - toasts.add( - `Update to v${v} installed but the app didn't restart. Quit and reopen Autonomi to finish updating.`, - 'error', - ) + toasts.add(t('updater.toast.install_no_restart', { version: v }), 'error') this.installing = false this._cleanupInstallListeners() }) @@ -139,7 +140,7 @@ export const useUpdaterStore = defineStore('updater', { return } console.error('Update install failed:', e) - toasts.add(`Update install failed: ${raw}`, 'error') + toasts.add(t('updater.toast.install_failed', { error: raw }), 'error') this.installing = false this._cleanupInstallListeners() } diff --git a/utils/validators.ts b/utils/validators.ts index 5b49d49..5770188 100644 --- a/utils/validators.ts +++ b/utils/validators.ts @@ -10,16 +10,20 @@ const RESERVED_FILENAME_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i /** * Validate a user-typed "Save as" filename against the cross-platform reserved - * set. Returns an error message for the UI, or null when the name is safe to - * pass to the OS. An empty/whitespace-only string returns null so the caller - * can decide whether emptiness is itself an error (most callers gate the - * submit button on `name.trim().length > 0` separately). + * set. Returns a translation key the caller can pass to `$t(...)`, or null + * when the name is safe to pass to the OS. An empty/whitespace-only string + * returns null so the caller can decide whether emptiness is itself an error + * (most callers gate the submit button on `name.trim().length > 0` separately). + * + * Returns keys instead of pre-translated strings so this module stays usable + * outside a Vue setup context — the caller is always rendering in a + * component, so it owns translation. */ export function filenameError(name: string): string | null { const trimmed = name.trim() if (trimmed.length === 0) return null - if (RESERVED_FILENAME_CHARS.test(trimmed)) return 'Filename cannot contain special characters' - if (/[. ]$/.test(trimmed)) return 'Filename cannot end with a dot or space' - if (RESERVED_FILENAME_NAMES.test(trimmed)) return `${trimmed} is a reserved name on Windows` + if (RESERVED_FILENAME_CHARS.test(trimmed)) return 'validators.filename.has_special_chars' + if (/[. ]$/.test(trimmed)) return 'validators.filename.ends_with_dot_or_space' + if (RESERVED_FILENAME_NAMES.test(trimmed)) return 'validators.filename.reserved_on_windows' return null }