From 6499bceef870c7bc111b8a18058951d98e03559d Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Tue, 12 May 2026 12:57:02 +0100 Subject: [PATCH 1/4] feat(i18n): vue-i18n framework + shell strings (English + Japanese baseline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs vue-i18n@11 with tauri-plugin-os locale detection, a persisted i18n_locale config field, and a Settings → Language picker showing "System default: " for the follow-system option. Extracts shell strings — sidebar nav, page titles, header connection pills, active transfers, Connect Wallet — to locales/en.json with a machine-translated locales/ja.json baseline. ja.json is flagged with _machine_translated: true; community polish to follow. Pages, components, toast strings, and validators are untouched — that sweep follows in a separate PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- app.vue | 7 ++ components/AppHeader.vue | 32 +++---- components/AppSidebar.vue | 26 +++--- composables/useLocale.ts | 80 ++++++++++++++++ locales/en.json | 34 +++++++ locales/ja.json | 35 +++++++ package-lock.json | 97 ++++++++++++++++++- package.json | 2 + pages/settings.vue | 43 +++++++++ plugins/i18n.client.ts | 21 +++++ src-tauri/Cargo.lock | 140 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 2 + src-tauri/src/config.rs | 6 ++ src-tauri/src/lib.rs | 1 + stores/settings.ts | 11 +++ 16 files changed, 506 insertions(+), 32 deletions(-) create mode 100644 composables/useLocale.ts create mode 100644 locales/en.json create mode 100644 locales/ja.json create mode 100644 plugins/i18n.client.ts 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/composables/useLocale.ts b/composables/useLocale.ts new file mode 100644 index 0000000..95e8889 --- /dev/null +++ b/composables/useLocale.ts @@ -0,0 +1,80 @@ +import { ref } from 'vue' +import { locale as osLocaleApi } from '@tauri-apps/plugin-os' +import { useI18n } from 'vue-i18n' +import { useSettingsStore } from '~/stores/settings' + +export const SUPPORTED_LOCALES = ['en', 'ja'] as const +export type SupportedLocale = typeof SUPPORTED_LOCALES[number] +const DEFAULT_LOCALE: SupportedLocale = 'en' + +/** Each locale's name in its own script. Used in the Settings picker so the + * user sees "English" / "日本語" regardless of the currently-active UI locale. */ +export const NATIVE_LOCALE_NAMES: Record = { + en: 'English', + ja: '日本語', +} + +/** Module-scoped cache of the OS-resolved locale. Warmed on the first + * detectOsLocale() call so the Settings picker can render + * "System default: " synchronously after app init. */ +const osLocaleRef = ref(null) + +function isSupported(value: string): value is SupportedLocale { + return (SUPPORTED_LOCALES as readonly string[]).includes(value) +} + +function normalizeLocale(raw: string | null | undefined): SupportedLocale { + if (!raw) return DEFAULT_LOCALE + const base = raw.toLowerCase().split('-')[0] + return isSupported(base) ? base : DEFAULT_LOCALE +} + +export async function detectOsLocale(): Promise { + if (osLocaleRef.value !== null) return osLocaleRef.value + try { + osLocaleRef.value = normalizeLocale(await osLocaleApi()) + } catch { + osLocaleRef.value = DEFAULT_LOCALE + } + return osLocaleRef.value +} + +export function useLocale() { + const { locale, t } = useI18n() + const settings = useSettingsStore() + + /** + * Resolve and apply the locale to use at app start. Order: + * 1. Persisted user choice (`settings.i18nLocale`) if supported. + * 2. OS locale via tauri-plugin-os, region-stripped. + * 3. Fallback `en`. + * + * Always warms the OS-locale cache so the Settings picker can render its + * "System default: " label without an extra round-trip. + */ + async function init() { + const persisted = settings.i18nLocale + if (persisted && isSupported(persisted)) { + locale.value = persisted + // Warm the OS cache for the Settings picker label even when the + // persisted choice overrides it. + detectOsLocale().catch(() => {}) + return + } + locale.value = await detectOsLocale() + } + + /** Switch the active locale. `null` means "follow system" — the persisted + * choice is cleared and the live locale falls back to the OS reading. */ + async function setLocale(next: SupportedLocale | null) { + if (next === null) { + await settings.setI18nLocale(null) + locale.value = await detectOsLocale() + return + } + await settings.setI18nLocale(next) + locale.value = next + } + + return { locale, setLocale, init, t, osLocale: osLocaleRef } +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..c6d4417 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,34 @@ +{ + "settings": { + "language": { + "label": "Language", + "description": "UI display language", + "system_default": "System default: {name}", + "system_default_pending": "System default" + } + }, + "nav": { + "nodes": "Nodes", + "files": "Files", + "wallet": "Wallet", + "settings": "Settings" + }, + "header": { + "title": "Autonomi", + "active_transfers": "{count} active", + "connecting": "Connecting", + "connecting_tooltip": "Connecting to the Autonomi network", + "connected": "Network", + "connected_tooltip": "Connected to the Autonomi network", + "offline": "Offline · Retry", + "offline_tooltip": "Connection failed — click to retry", + "connect_wallet": "Connect Wallet" + }, + "sidebar": { + "pre_release": "PRE-RELEASE", + "update_available": "Update Available", + "update_pre_release_tag": "Pre-Release", + "network_devnet": "DEVNET", + "network_sepolia": "SEPOLIA TESTNET" + } +} diff --git a/locales/ja.json b/locales/ja.json new file mode 100644 index 0000000..2ebc9c8 --- /dev/null +++ b/locales/ja.json @@ -0,0 +1,35 @@ +{ + "_machine_translated": true, + "settings": { + "language": { + "label": "言語", + "description": "UI 表示言語", + "system_default": "システムのデフォルト: {name}", + "system_default_pending": "システムのデフォルト" + } + }, + "nav": { + "nodes": "ノード", + "files": "ファイル", + "wallet": "ウォレット", + "settings": "設定" + }, + "header": { + "title": "Autonomi", + "active_transfers": "{count} 件処理中", + "connecting": "接続中", + "connecting_tooltip": "Autonomi ネットワークに接続中", + "connected": "ネットワーク", + "connected_tooltip": "Autonomi ネットワークに接続済み", + "offline": "オフライン · 再試行", + "offline_tooltip": "接続に失敗しました — クリックして再試行", + "connect_wallet": "ウォレットを接続" + }, + "sidebar": { + "pre_release": "プレリリース", + "update_available": "アップデート利用可能", + "update_pre_release_tag": "プレリリース", + "network_devnet": "DEVNET", + "network_sepolia": "SEPOLIA テストネット" + } +} diff --git a/package-lock.json b/package-lock.json index ae4d91b..7816bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ant-gui", - "version": "0.6.7", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ant-gui", - "version": "0.6.7", + "version": "0.7.0", "dependencies": { "@pinia/nuxt": "^0.5.0", "@reown/appkit": "^1.8.19", @@ -14,6 +14,7 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", "@wagmi/core": "^3.4.0", @@ -21,6 +22,7 @@ "pinia": "^2.2.0", "viem": "^2.47.1", "vue": "^3.5.0", + "vue-i18n": "^11.4.2", "vue-router": "^4.4.0" }, "devDependencies": { @@ -1063,6 +1065,67 @@ "node": ">=18" } }, + "node_modules/@intlify/core-base": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz", + "integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.4.2", + "@intlify/message-compiler": "11.4.2", + "@intlify/shared": "11.4.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz", + "integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.2", + "@intlify/shared": "11.4.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz", + "integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.4.2", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz", + "integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@ioredis/commands": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", @@ -5448,6 +5511,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", + "integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-process": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", @@ -14532,6 +14604,27 @@ "integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==", "license": "MIT" }, + "node_modules/vue-i18n": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz", + "integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.2", + "@intlify/devtools-types": "11.4.2", + "@intlify/shared": "11.4.2", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", diff --git a/package.json b/package.json index 27d5e17..edfcefe 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", "@wagmi/core": "^3.4.0", @@ -27,6 +28,7 @@ "pinia": "^2.2.0", "viem": "^2.47.1", "vue": "^3.5.0", + "vue-i18n": "^11.4.2", "vue-router": "^4.4.0" }, "devDependencies": { diff --git a/pages/settings.vue b/pages/settings.vue index 706fffd..4038985 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -107,6 +107,23 @@ + +
+
+

{{ $t('settings.language.label') }}

+

{{ $t('settings.language.description') }}

+
+ +
+
@@ -82,14 +82,14 @@ class="rounded-md border border-autonomi-border px-3 py-1.5 text-sm text-autonomi-muted hover:text-autonomi-text" @click="close" > - Not Now + {{ $t('updater.dialog.not_now') }}
diff --git a/components/files/DatamapSaveAsDialog.vue b/components/files/DatamapSaveAsDialog.vue index cfb38fb..124a9e7 100644 --- a/components/files/DatamapSaveAsDialog.vue +++ b/components/files/DatamapSaveAsDialog.vue @@ -22,7 +22,7 @@ @keyup.enter="confirm" @keyup.escape="$emit('close')" /> -

{{ filenameError }}

+

{{ $t(filenameError) }}

diff --git a/components/files/DownloadDialog.vue b/components/files/DownloadDialog.vue index 66726e8..4c56fa8 100644 --- a/components/files/DownloadDialog.vue +++ b/components/files/DownloadDialog.vue @@ -34,7 +34,7 @@ @input="onFilenameInput" @keyup.enter="confirm" /> -

{{ filenameError }}

+

{{ $t(filenameError) }}

diff --git a/locales/en.json b/locales/en.json index ff97111..acf1f3c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -223,6 +223,23 @@ "no_earnings_warning": "No earnings address configured. Set one in the Wallet page before adding nodes.", "submit_one": "Add 1 Node", "submit_many": "Add {count} Nodes" + }, + "toast": { + "added": "Added {count} node(s)", + "add_failed": "Failed to add nodes: {error}", + "started": "Node {id} started", + "start_failed": "Failed to start node {id}: {error}", + "stopped": "Node {id} stopped", + "stop_failed": "Failed to stop node {id}: {error}", + "removed": "Node {id} removed", + "remove_failed": "Failed to remove node {id}: {error}", + "no_stopped": "No stopped nodes to start", + "no_running": "No running nodes to stop", + "node_failed": "Node {id} failed: {error}", + "started_count": "Started {count} node(s)", + "stopped_count": "Stopped {count} node(s)", + "start_all_failed": "Failed to start all: {error}", + "stop_all_failed": "Failed to stop all: {error}" } }, "files": { @@ -285,7 +302,14 @@ "public_address_copied": "Public address copied — share to let others download this file", "datamap_path_copied": "Datamap path copied to clipboard", "already_stored_one": "1 file already stored — saving datamap", - "already_stored_many": "{count} files already stored — saving datamap" + "already_stored_many": "{count} files already stored — saving datamap", + "upload_complete": "Upload complete: {name}", + "upload_failed": "Upload failed: {name} — {error}", + "payment_failed": "Payment failed: {error}", + "download_complete": "Download complete: {name}", + "download_failed": "Download failed: {name} — {error}", + "datamap_missing": "Cannot download: datamap file missing or unreadable", + "datamap_read_failed": "Could not read datamap: {error}" }, "error": { "wallet_not_connected": "Wallet not connected", @@ -383,6 +407,34 @@ "wallet_disconnected": "Wallet disconnected" } }, + "updater": { + "dialog": { + "title": "Update Available", + "version_ready": "v{version} is ready to install", + "pre_release_badge": "Pre-release", + "download_size": "Download size: {size}", + "release_notes": "Release Notes", + "no_release_notes": "No release notes available for this version.", + "downloading": "Downloading…", + "download_complete": "Download succeeded, the app will restart shortly", + "auto_restart_hint": "The app will restart automatically when complete.", + "cancel_download": "Cancel Download", + "installing_tooltip": "Installing — please wait", + "not_now": "Not Now", + "update_restart": "Update & Restart" + }, + "toast": { + "install_no_restart": "Update to v{version} installed but the app didn't restart. Quit and reopen Autonomi to finish updating.", + "install_failed": "Update install failed: {error}" + } + }, + "validators": { + "filename": { + "has_special_chars": "Filename cannot contain special characters", + "ends_with_dot_or_space": "Filename cannot end with a dot or space", + "reserved_on_windows": "That name is reserved on Windows" + } + }, "status": { "node": { "running": "Running", diff --git a/locales/ja.json b/locales/ja.json index 44435cb..3dfec79 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -224,6 +224,23 @@ "no_earnings_warning": "報酬アドレスが設定されていません。ノードを追加する前にウォレットページで設定してください。", "submit_one": "1 ノードを追加", "submit_many": "{count} ノードを追加" + }, + "toast": { + "added": "{count} ノードを追加しました", + "add_failed": "ノードの追加に失敗: {error}", + "started": "ノード {id} を開始しました", + "start_failed": "ノード {id} の開始に失敗: {error}", + "stopped": "ノード {id} を停止しました", + "stop_failed": "ノード {id} の停止に失敗: {error}", + "removed": "ノード {id} を削除しました", + "remove_failed": "ノード {id} の削除に失敗: {error}", + "no_stopped": "開始できる停止中のノードがありません", + "no_running": "停止できる実行中のノードがありません", + "node_failed": "ノード {id} 失敗: {error}", + "started_count": "{count} ノードを開始しました", + "stopped_count": "{count} ノードを停止しました", + "start_all_failed": "すべての開始に失敗: {error}", + "stop_all_failed": "すべての停止に失敗: {error}" } }, "files": { @@ -286,7 +303,14 @@ "public_address_copied": "公開アドレスをコピーしました — 他の人がこのファイルをダウンロードできるよう共有してください", "datamap_path_copied": "データマップパスをクリップボードにコピーしました", "already_stored_one": "1 ファイルが既に保存されています — データマップを保存中", - "already_stored_many": "{count} ファイルが既に保存されています — データマップを保存中" + "already_stored_many": "{count} ファイルが既に保存されています — データマップを保存中", + "upload_complete": "アップロード完了: {name}", + "upload_failed": "アップロード失敗: {name} — {error}", + "payment_failed": "支払い失敗: {error}", + "download_complete": "ダウンロード完了: {name}", + "download_failed": "ダウンロード失敗: {name} — {error}", + "datamap_missing": "ダウンロードできません: データマップファイルが見つからないか読み取れません", + "datamap_read_failed": "データマップを読み取れませんでした: {error}" }, "error": { "wallet_not_connected": "ウォレットが接続されていません", @@ -384,6 +408,34 @@ "wallet_disconnected": "ウォレットを切断しました" } }, + "updater": { + "dialog": { + "title": "アップデートあり", + "version_ready": "v{version} のインストール準備が完了", + "pre_release_badge": "プレリリース", + "download_size": "ダウンロードサイズ: {size}", + "release_notes": "リリースノート", + "no_release_notes": "このバージョンのリリースノートはありません。", + "downloading": "ダウンロード中…", + "download_complete": "ダウンロード成功 — 間もなくアプリが再起動します", + "auto_restart_hint": "完了するとアプリが自動的に再起動します。", + "cancel_download": "ダウンロードをキャンセル", + "installing_tooltip": "インストール中 — お待ちください", + "not_now": "後で", + "update_restart": "アップデートして再起動" + }, + "toast": { + "install_no_restart": "v{version} のアップデートはインストールされましたが、アプリが再起動しませんでした。Autonomi を終了して開き直してください。", + "install_failed": "アップデートのインストールに失敗: {error}" + } + }, + "validators": { + "filename": { + "has_special_chars": "ファイル名に特殊文字を含めることはできません", + "ends_with_dot_or_space": "ファイル名はドットまたはスペースで終わることはできません", + "reserved_on_windows": "この名前は Windows の予約名です" + } + }, "status": { "node": { "running": "実行中", diff --git a/plugins/i18n.client.ts b/plugins/i18n.client.ts index c9d7e50..f8825d8 100644 --- a/plugins/i18n.client.ts +++ b/plugins/i18n.client.ts @@ -2,15 +2,21 @@ 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) => { - const i18n = createI18n({ - legacy: false, - locale: 'en', - fallbackLocale: 'en', - messages: { en, ja }, - missingWarn: true, - fallbackWarn: false, - }) nuxtApp.vueApp.use(i18n) // Dev-only handle for manual locale-swap testing from DevTools console. 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/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 }