From b731ec50d0cf787c2f57a6295bcb44333d048829 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 3 Sep 2026 16:21:01 +0800 Subject: [PATCH] chinese localization!! --- Cargo.lock | 10 + bun.lock | 43 ++++ messages/en.json | 191 ++++++++++++++++ messages/zh-CN.json | 191 ++++++++++++++++ package.json | 4 +- project.inlang/settings.json | 12 + scripts/check-i18n.mjs | 38 ++++ src-tauri/Cargo.toml | 1 + .../20260903000000_add_locale_preference.sql | 2 + src-tauri/src/application/mod.rs | 2 +- src-tauri/src/db/mod.rs | 14 +- src-tauri/src/images/mod.rs | 5 +- src-tauri/src/lib.rs | 5 + src-tauri/src/onboarding/mod.rs | 50 ++--- src-tauri/src/profile/mod.rs | 5 + src-tauri/src/settings/mod.rs | 205 ++++++++++++++++++ src-tauri/src/ui/mod.rs | 4 + src-tauri/src/ui/onboarding.rs | 5 +- src-tauri/src/ui/scene/mod.rs | 5 +- src-tauri/src/ui/tray.rs | 37 +++- src-tauri/src/user/mod.rs | 1 + src/app.css | 16 +- src/lib/bindings.ts | 11 +- .../control-panel/friends-panel.svelte | 50 +++-- .../control-panel/general-panel.svelte | 73 +++++-- .../control-panel/network-panel.svelte | 58 ++--- .../control-panel/scene-panel.svelte | 39 ++-- src/lib/i18n/index.ts | 138 ++++++++++++ src/routes/+layout.svelte | 48 +++- src/routes/control-panel/+page.svelte | 26 ++- .../control-panel/add/friend/+page.svelte | 40 ++-- .../control-panel/add/network/+page.svelte | 25 ++- .../edit/network/[id]/+page.svelte | 39 ++-- src/routes/onboarding/+page.svelte | 90 ++++---- .../components/accessibility-step.svelte | 23 +- .../components/complete-step.svelte | 9 +- .../onboarding/components/friend-step.svelte | 18 +- .../components/identity-step.svelte | 7 +- .../components/movement-step.svelte | 5 +- .../onboarding/components/server-step.svelte | 14 +- .../onboarding/components/skin-step.svelte | 11 +- .../onboarding/components/welcome-step.svelte | 51 ++++- src/routes/scene/+page.svelte | 12 +- .../components/renderer/puppet/preview.svelte | 3 +- src/routes/scene/popovers/image-viewer.svelte | 7 +- .../scene/popovers/interaction-bubble.svelte | 11 +- .../scene/popovers/user-interaction.svelte | 33 +-- ...rk-pixel-12px-proportional-zh_cn.ttf.woff2 | Bin 0 -> 749688 bytes vite.config.js | 12 +- 49 files changed, 1404 insertions(+), 295 deletions(-) create mode 100644 messages/en.json create mode 100644 messages/zh-CN.json create mode 100644 project.inlang/settings.json create mode 100644 scripts/check-i18n.mjs create mode 100644 src-tauri/migrations/20260903000000_add_locale_preference.sql create mode 100644 src-tauri/src/settings/mod.rs create mode 100644 src/lib/i18n/index.ts create mode 100644 static/ark-pixel-12px-proportional-zh_cn.ttf.woff2 diff --git a/Cargo.lock b/Cargo.lock index 8b672f0..b6695b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "specta", "specta-typescript", "sqlx", + "sys-locale", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4597,6 +4598,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 = "system-configuration" version = "0.7.0" diff --git a/bun.lock b/bun.lock index 608fede..bb6d705 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "three": "^0.185.1", }, "devDependencies": { + "@inlang/paraglide-js": "^2.25.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", @@ -81,6 +82,12 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@inlang/paraglide-js": ["@inlang/paraglide-js@2.25.0", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^3.0.1", "commander": "11.1.0", "consola": "3.4.0", "jiti": "^2.7.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0", "valibot": "^1.4.2" }, "peerDependencies": { "typescript": ">=5.6", "vite": ">=5.0.0" }, "optionalPeers": ["typescript", "vite"], "bin": { "paraglide-js": "bin/run.js" } }, "sha512-a3Gw3md1S3WyJK1rFAP/FwDCsFKnu1b98rJ3k5bUl6nc3cmbglaSYpuK9qq9aLLlUlZhuCtB/Pb1LeKDUaLycg=="], + + "@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="], + + "@inlang/sdk": ["@inlang/sdk@3.0.3", "", { "dependencies": { "@lix-js/sdk": "0.12.3", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "uuid": "^14.0.0" } }, "sha512-11aQcOw58oAcCtIOJ17XhQxfxaiGsg6XJi7coMul8wNJiW+Rzv2OfJU42NLwSuWXUNSASjegp5Z3uklTkY5GJw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -91,6 +98,16 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lix-js/sdk": ["@lix-js/sdk@0.12.3", "", { "dependencies": { "fflate": "^0.8.3" }, "optionalDependencies": { "@lix-js/sdk-darwin-arm64": "0.12.3", "@lix-js/sdk-linux-arm64": "0.12.3", "@lix-js/sdk-linux-x64": "0.12.3", "@lix-js/sdk-win32-x64": "0.12.3" } }, "sha512-T2194yoZsM2S9kyRmI8UtEJL0fgXYAkeY9jZ/PVyC0K78/wmBvrqOSt9SdvgYxM5qnZ57re3NLOjVlV+X5hWxQ=="], + + "@lix-js/sdk-darwin-arm64": ["@lix-js/sdk-darwin-arm64@0.12.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YeMJyYGk+QwvQe8O4AAcBMgfNm68IjAGDvuvZHr1U1rIb3GC2kYFStajqf4mT4BGc+cN65/P/2kkqEo+Igu/sA=="], + + "@lix-js/sdk-linux-arm64": ["@lix-js/sdk-linux-arm64@0.12.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jtmfNg6jLW61l1QvsU3W184l3hGtYUyPGzZrQyekk0vsMF1hxsP++7ONEGNI19PE7ldwSnGCB8Q6OzRKX87abQ=="], + + "@lix-js/sdk-linux-x64": ["@lix-js/sdk-linux-x64@0.12.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PiQL4h1FJXbxnZx/PgenI5grgpFtYlS/ZtPtTYh638YHVu+6J0cqGY/pLVqP8CKv1dWAKsZ2YgftLd+1j2+WKA=="], + + "@lix-js/sdk-win32-x64": ["@lix-js/sdk-win32-x64@0.12.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hnxMI7kPS4ngXs0ZVnOve5CmtC4+vaNIyNDi/yWU0rSvxj7mqw/1RCegTrCB7LjxlX4DG+eg9HruXurk9NDjGg=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.61.1", "", { "os": "android", "cpu": "arm" }, "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA=="], @@ -143,6 +160,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.61.1", "", { "os": "win32", "cpu": "x64" }, "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw=="], + "@sinclair/typebox": ["@sinclair/typebox@0.31.30", "", {}, "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], @@ -235,12 +254,20 @@ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], + + "consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "daisyui": ["daisyui@5.7.21", "", {}, "sha512-DyJvCwrclM8K3Jp8TB9n2WZ+QUd+0/dSEY0WRvTE26wL06e8I6nEpKYYJWJ5SDRwc7FRDp3CyeREqYTHi9hM3A=="], @@ -259,6 +286,8 @@ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esrap": ["esrap@2.2.11", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -273,8 +302,12 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -347,10 +380,20 @@ "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], + + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..52252e8 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "language_system": "Use system setting", + "language_english": "English", + "language_chinese": "Chinese (中文)", + "language_label": "Language", + "common_ok": "OK", + "common_cancel": "Cancel", + "common_apply": "Apply", + "common_applying": "Applying…", + "common_add": "Add…", + "common_remove": "Remove", + "common_edit": "Edit…", + "common_save": "Save", + "common_saving": "Saving…", + "common_reset": "Reset", + "common_copy": "Copy", + "common_unavailable": "Unavailable", + "common_loading": "Loading…", + "common_online": "Online", + "common_offline": "Offline", + "common_name": "Name", + "common_status": "Status", + "common_address": "Address", + "common_optional_port": "Port (optional)", + "common_optional_friendly_name": "Friendly name (optional)", + "common_server_details": "Server details", + "common_friend_identity": "Friend identity", + "common_identification_key": "Identification Key", + "common_display_name": "Display name", + "common_unknown_friend": "Unknown friend", + "common_unknown_user": "Unknown user", + "common_unresolved": "Unresolved", + "common_unresolved_user": "Unresolved user", + "common_resolving": "Resolving…", + "error_unexpected": "Something went wrong. Please try again.", + "error_display_name_empty": "Display name cannot be empty.", + "error_server_address_required": "Server address is required.", + "error_port_invalid": "Port must be a whole number from 1 to 65535.", + "error_friend_key_required": "Identification Key is required.", + "error_server_not_selected": "No server was selected.", + "error_server_missing": "This server no longer exists.", + "error_friend_missing": "The selected friend no longer exists.", + "error_profile_loading": "Your profile is still loading.", + "error_skin_required": "Choose a 64×64 PNG skin before continuing.", + "error_accessibility_required": "Turn on Friendolls in macOS Accessibility settings before continuing.", + "error_key_invalid_base64": "Identification Key is not valid base64url.", + "error_key_invalid_length": "Identification Key must encode a 32-byte public key.", + "error_key_invalid_ed25519": "Identification Key is not a valid Ed25519 public key.", + "error_own_key": "You cannot add your own Identification Key.", + "error_image_not_local": "The selected image is not a local file.", + "error_image_unreadable": "The selected image could not be read.", + "error_image_too_large": "The selected image must be at most {max} MiB.", + "error_image_choose": "Choose an image file.", + "error_image_decode": "The selected image could not be decoded.", + "error_image_compression": "Image compression failed.", + "error_image_too_detailed": "Image is still too detailed after compression.", + "error_friend_unreachable": "No connected server can currently reach this friend.", + "error_friend_busy": "Your friend is busy. Try again.", + "error_interaction_rejected": "The server rejected the interaction.", + "error_friend_unavailable": "Your friend is no longer available.", + "error_skin_invalid_png": "Skin must be a valid PNG image.", + "error_skin_dimensions": "Skin must be {width}×{height} pixels.", + "onboarding_window_title": "Friendolls Setup", + "onboarding_brand": "Friendolls\nSetup", + "onboarding_welcome_title": "Welcome to Friendolls!", + "onboarding_welcome_nav": "Welcome", + "onboarding_identity_title": "Choose your display name", + "onboarding_identity_nav": "Display Name", + "onboarding_skin_title": "Choose a puppet skin", + "onboarding_skin_nav": "Puppet's Skin", + "onboarding_movement_title": "Choose your Movement Mode", + "onboarding_movement_nav": "Movement Mode", + "onboarding_server_title": "Add a server", + "onboarding_server_nav": "Add a server", + "onboarding_friend_title": "Add a friend", + "onboarding_friend_nav": "Add a friend", + "onboarding_accessibility_title": "Allow cursor access", + "onboarding_accessibility_nav": "Cursor access", + "onboarding_complete_title": "Setup is complete", + "onboarding_complete_nav": "Complete", + "onboarding_welcome_intro": "Welcome to the Friendolls setup wizard, which will guide you through setting up Friendolls.", + "onboarding_welcome_description": "Friendolls turns your friends into desktop puppets that follow their cursors and appear when they are online.", + "onboarding_identity_legend": "Identity", + "onboarding_identity_help": "Up to 64 characters. You can change it later.", + "onboarding_skin_legend": "Puppet skin", + "onboarding_skin_keep": "Keep current skin", + "onboarding_skin_default": "Use default skin", + "onboarding_skin_choose_png": "Choose a PNG file", + "onboarding_skin_help": "Skins must be exactly 64×64 pixels.", + "puppet_movement": "Puppets movement", + "puppet_free_alt": "Puppets moving freely across the full viewport", + "puppet_free": "Freeroam around", + "puppet_bottom_alt": "Puppets moving along the bottom of the viewport", + "puppet_bottom": "Stay on the ground", + "onboarding_server_skip": "If you do not have a server yet, leave this empty and continue. You can add one later.", + "onboarding_friend_skip": "If no one has shared a key with you yet, leave this empty and continue. You can add friends anytime afterwards.", + "friend_key_verify": "Verify this key with your friend before adding it.", + "accessibility_granted": "Accessibility access is granted. Puppets can now follow your cursor.", + "accessibility_waiting": "Waiting for Accessibility permission.", + "accessibility_why": "Why Friendolls needs this", + "accessibility_explanation": "Friendolls reads the system cursor position so your puppet can follow you and your friends can see that movement. It does not use Accessibility access to read typed text, click buttons, or control other apps.", + "accessibility_step_open": "Click Open Accessibility Settings.", + "accessibility_step_enable": "Turn on Friendolls in Privacy & Security → Accessibility.", + "accessibility_step_return": "Return here; this wizard confirms the change automatically.", + "accessibility_access_granted": "Access granted", + "accessibility_open_settings": "Open Accessibility Settings…", + "accessibility_settings_opened": "macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.", + "onboarding_complete_body": "Click Finish to save setup and launch Friendolls. The control panel remains available from the menu bar or system tray.", + "onboarding_loading": "Loading setup", + "onboarding_permission_repair": "Permission repair", + "onboarding_step_count": "Step {current} of {total}", + "onboarding_back": "< Back", + "onboarding_working": "Working…", + "onboarding_done": "Done", + "onboarding_finish": "Finish", + "onboarding_next": "Next >", + "control_window_title": "Friendolls Properties", + "control_tab_scene": "Scene", + "control_tab_account": "Account", + "control_tab_friends": "Friends", + "control_tab_network": "Network", + "account_identity": "Identity", + "account_name_help": "This name can be changed at any time.", + "account_key_trust": "Share with people you're befriending.", + "account_public_key": "Identification Key", + "account_copy_key": "Copy Identification Key", + "account_loading_key": "Loading Identification Key", + "account_ui_label": "UI", + "friends_remove_prefix": "Applying will remove ", + "friends_remove_suffix": ". They will immediately go offline and stop receiving your activity.", + "friends_keep": "Keep friend", + "friends_list_label": "Friends", + "friends_empty": "No friends yet", + "friends_empty_help": "Add someone using their Identification Key.", + "friends_resolve_online": "Will resolve when they're online", + "network_remove_prefix": "Applying will remove ", + "network_remove_suffix": ". Friendolls will stop connecting to it.", + "network_keep": "Keep server", + "network_list_label": "Servers", + "network_empty": "No servers yet", + "network_empty_help": "Add a server to connect with friends.", + "network_connecting": "Connecting", + "network_connected": "Connected", + "network_disconnected": "Disconnected", + "network_move_up": "Move server up", + "network_move_down": "Move server down", + "network_server": "Server", + "network_connection": "Connection", + "network_priority": "Priority", + "network_configured_servers": "Configured servers", + "network_no_servers": "No servers configured", + "network_existing_connections_stop": ". Existing connections through this server may stop.", + "scene_all_puppets": "All puppets", + "scene_scale": "Scale", + "scene_opacity": "Opacity", + "scene_choose_skin": "Choose a skin", + "scene_loading_preview": "Loading puppet preview", + "action_add_friend_title": "Add Friend", + "action_add_server_title": "Add Server", + "action_edit_server_title": "Edit Server", + "action_adding": "Adding…", + "action_friend_profile_help": "Their display name will appear after Friendolls learns their profile from a shared server.", + "action_friend_verify": "Verify with your friend before adding.", + "action_edit_server_heading": "Edit server", + "action_edit_server_help": "Update how Friendolls connects to this remote server.", + "scene_show_user": "Show live user information", + "scene_window_title": "Scene", + "scene_close_interaction": "Close interaction popover", + "scene_currently_enjoying": "Currently enjoying", + "scene_waiting_data": "Waiting for data", + "scene_message": "Message", + "scene_wave": "Wave", + "scene_image": "Image", + "scene_write_to": "Write to {username}", + "scene_message_user": "Message {username}", + "scene_sending": "Sending…", + "scene_send": "Send", + "scene_choose_or_paste": "Choose or paste an image", + "scene_compression_note": "Compressed to 480 px · 150 KiB", + "scene_compressing": "Compressing…", + "scene_choose_image": "Choose image", + "interaction_dismiss": "Dismiss interaction", + "interaction_waved": "waved", + "interaction_open_image": "Open image from {sender}", + "interaction_preview_image": "Preview sent by {sender}", + "interaction_enlarge": "Click to enlarge", + "image_from": "Image from {sender}", + "image_sent_by": "Image sent by {sender}", + "image_close": "Close image" +} diff --git a/messages/zh-CN.json b/messages/zh-CN.json new file mode 100644 index 0000000..dc7e9ca --- /dev/null +++ b/messages/zh-CN.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "language_system": "跟随系统", + "language_english": "英语(English)", + "language_chinese": "中文", + "language_label": "语言", + "common_ok": "确定", + "common_cancel": "取消", + "common_apply": "应用", + "common_applying": "正在应用…", + "common_add": "添加…", + "common_remove": "删除", + "common_edit": "编辑…", + "common_save": "保存", + "common_saving": "正在保存…", + "common_reset": "重置", + "common_copy": "复制", + "common_unavailable": "不可用", + "common_loading": "正在加载…", + "common_online": "在线", + "common_offline": "离线", + "common_name": "名称", + "common_status": "状态", + "common_address": "地址", + "common_optional_port": "端口(可选)", + "common_optional_friendly_name": "备注名称(可选)", + "common_server_details": "服务器信息", + "common_friend_identity": "好友身份", + "common_identification_key": "身份密钥", + "common_display_name": "显示名称", + "common_unknown_friend": "未知好友", + "common_unknown_user": "未知用户", + "common_unresolved": "未解析", + "common_unresolved_user": "未解析用户", + "common_resolving": "正在解析…", + "error_unexpected": "发生错误,请重试。", + "error_display_name_empty": "显示名称不能为空。", + "error_server_address_required": "服务器地址不能为空。", + "error_port_invalid": "端口必须是 1 到 65535 之间的整数。", + "error_friend_key_required": "请输入身份密钥。", + "error_server_not_selected": "尚未选择服务器。", + "error_server_missing": "该服务器已不存在。", + "error_friend_missing": "所选好友已不存在。", + "error_profile_loading": "个人资料仍在加载中。", + "error_skin_required": "请选择一张 64×64 的 PNG 皮肤图片后再继续。", + "error_accessibility_required": "请先在 macOS 的“辅助功能”设置中启用 Friendolls。", + "error_key_invalid_base64": "身份密钥不是有效的 base64url 内容。", + "error_key_invalid_length": "身份密钥必须编码一个 32 字节的公钥。", + "error_key_invalid_ed25519": "身份密钥不是有效的 Ed25519 公钥。", + "error_own_key": "不能将自己的身份密钥添加为好友。", + "error_image_not_local": "所选图片不是本地文件。", + "error_image_unreadable": "无法读取所选图片。", + "error_image_too_large": "所选图片不能超过 {max} MiB。", + "error_image_choose": "请选择图片文件。", + "error_image_decode": "无法解码所选图片。", + "error_image_compression": "图片压缩失败。", + "error_image_too_detailed": "图片细节过多,压缩后仍超过大小限制。", + "error_friend_unreachable": "当前没有已连接的服务器能够联系到该好友。", + "error_friend_busy": "好友正忙,请稍后重试。", + "error_interaction_rejected": "服务器拒绝了此次互动。", + "error_friend_unavailable": "好友已不可用。", + "error_skin_invalid_png": "皮肤必须是有效的 PNG 图片。", + "error_skin_dimensions": "皮肤图片必须为 {width}×{height} 像素。", + "onboarding_window_title": "Friendolls 安装向导", + "onboarding_brand": "Friendolls\n安装向导", + "onboarding_welcome_title": "欢迎使用 Friendolls!", + "onboarding_welcome_nav": "欢迎", + "onboarding_identity_title": "设置显示名称", + "onboarding_identity_nav": "显示名称", + "onboarding_skin_title": "选择玩偶皮肤", + "onboarding_skin_nav": "玩偶皮肤", + "onboarding_movement_title": "选择一个移动模式", + "onboarding_movement_nav": "移动模式", + "onboarding_server_title": "添加服务器", + "onboarding_server_nav": "添加服务器", + "onboarding_friend_title": "添加好友", + "onboarding_friend_nav": "添加好友", + "onboarding_accessibility_title": "允许访问光标位置", + "onboarding_accessibility_nav": "光标权限", + "onboarding_complete_title": "设置已完成", + "onboarding_complete_nav": "完成", + "onboarding_welcome_intro": "欢迎使用 Friendolls 安装向导。本向导将帮助你完成 Friendolls 的初始设置。", + "onboarding_welcome_description": "Friendolls 会将好友变成桌面玩偶。好友在线时,玩偶便会出现并跟随对方的光标移动。", + "onboarding_identity_legend": "身份信息", + "onboarding_identity_help": "最多 64 个字符,之后仍可修改。", + "onboarding_skin_legend": "玩偶皮肤", + "onboarding_skin_keep": "保留当前皮肤", + "onboarding_skin_default": "使用默认皮肤", + "onboarding_skin_choose_png": "选择 PNG 文件", + "onboarding_skin_help": "皮肤图片必须为 64×64 像素。", + "puppet_movement": "玩偶移动模式", + "puppet_free_alt": "玩偶可在整个屏幕范围内自由移动", + "puppet_free": "自由移动", + "puppet_bottom_alt": "玩偶沿屏幕底部移动", + "puppet_bottom": "贴地移动", + "onboarding_server_skip": "如果目前没有服务器,可以留空并继续,之后随时可以添加。", + "onboarding_friend_skip": "如果还没有好友向你分享密钥,可以留空并继续,之后随时可以添加好友。", + "friend_key_verify": "添加前,请与好友核对该密钥。", + "accessibility_granted": "已获得辅助功能权限,玩偶现在可以跟随你的光标。", + "accessibility_waiting": "正在等待辅助功能权限。", + "accessibility_why": "Friendolls 为什么需要此权限", + "accessibility_explanation": "Friendolls 会读取系统光标位置,让你的玩偶跟随光标移动,并让好友看到这一动作。Friendolls 不会利用辅助功能权限读取你输入的文字、点击按钮或控制其他应用。", + "accessibility_step_open": "点击“打开辅助功能设置”。", + "accessibility_step_enable": "在“隐私与安全性”→“辅助功能”中启用 Friendolls。", + "accessibility_step_return": "返回此处;本向导会自动确认权限状态。", + "accessibility_access_granted": "已授予权限", + "accessibility_open_settings": "打开辅助功能设置…", + "accessibility_settings_opened": "macOS 已打开“辅助功能”设置。请在其中启用 Friendolls,返回后此页面会自动确认权限状态。", + "onboarding_complete_body": "点击“完成”保存设置并启动 Friendolls。之后仍可从菜单栏或系统托盘打开控制面板。", + "onboarding_loading": "正在加载设置向导", + "onboarding_permission_repair": "修复权限", + "onboarding_step_count": "第 {current} 步,共 {total} 步", + "onboarding_back": "< 上一步", + "onboarding_working": "正在处理…", + "onboarding_done": "完成", + "onboarding_finish": "完成", + "onboarding_next": "下一步 >", + "control_window_title": "Friendolls 属性", + "control_tab_scene": "场景", + "control_tab_account": "账户", + "control_tab_friends": "好友", + "control_tab_network": "网络", + "account_identity": "身份信息", + "account_name_help": "显示名称可以随时修改。", + "account_key_trust": "与你要交的好友分享。", + "account_public_key": "身份密钥", + "account_copy_key": "复制身份密钥", + "account_loading_key": "正在加载身份密钥", + "account_ui_label": "界面", + "friends_remove_prefix": "应用更改将删除好友“", + "friends_remove_suffix": "”。对方会立即离线,并停止接收你的活动信息。", + "friends_keep": "保留好友", + "friends_list_label": "好友", + "friends_empty": "还没有好友", + "friends_empty_help": "使用对方的身份密钥添加好友。", + "friends_resolve_online": "对方上线后将自动解析", + "network_remove_prefix": "应用更改将删除服务器“", + "network_remove_suffix": "”。Friendolls 将停止连接该服务器。", + "network_keep": "保留服务器", + "network_list_label": "服务器", + "network_empty": "还没有服务器", + "network_empty_help": "添加服务器以连接好友。", + "network_connecting": "正在连接", + "network_connected": "已连接", + "network_disconnected": "未连接", + "network_move_up": "上移服务器", + "network_move_down": "下移服务器", + "network_server": "服务器", + "network_connection": "连接", + "network_priority": "优先级", + "network_configured_servers": "已配置的服务器", + "network_no_servers": "尚未配置服务器", + "network_existing_connections_stop": "”。经由该服务器建立的现有连接可能会中断。", + "scene_all_puppets": "所有玩偶", + "scene_scale": "缩放", + "scene_opacity": "不透明度", + "scene_choose_skin": "选择皮肤", + "scene_loading_preview": "正在加载玩偶预览", + "action_add_friend_title": "添加好友", + "action_add_server_title": "添加服务器", + "action_edit_server_title": "编辑服务器", + "action_adding": "正在添加…", + "action_friend_profile_help": "Friendolls 从共同服务器获取对方的个人资料后,会显示对方的名称。", + "action_friend_verify": "添加前请与好友核对。", + "action_edit_server_heading": "编辑服务器", + "action_edit_server_help": "更新 Friendolls 连接此远程服务器的方式。", + "scene_show_user": "显示用户实时信息", + "scene_window_title": "场景", + "scene_close_interaction": "关闭互动面板", + "scene_currently_enjoying": "正在享用", + "scene_waiting_data": "等待数据", + "scene_message": "消息", + "scene_wave": "挥手", + "scene_image": "图片", + "scene_write_to": "给 {username} 留言", + "scene_message_user": "给 {username} 留言", + "scene_sending": "正在发送…", + "scene_send": "发送", + "scene_choose_or_paste": "选择或粘贴图片", + "scene_compression_note": "将压缩至 480 像素 · 150 KiB", + "scene_compressing": "正在压缩…", + "scene_choose_image": "选择图片", + "interaction_dismiss": "关闭互动消息", + "interaction_waved": "向你挥了挥手", + "interaction_open_image": "打开 {sender} 发来的图片", + "interaction_preview_image": "{sender} 发来的图片预览", + "interaction_enlarge": "点击放大", + "image_from": "{sender} 发来的图片", + "image_sent_by": "{sender} 发来的图片", + "image_close": "关闭图片" +} diff --git a/package.json b/package.json index 9c2e2f4..152bf1b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "bun run check:i18n && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:i18n": "node scripts/check-i18n.mjs", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "tauri": "tauri" }, @@ -21,6 +22,7 @@ "three": "^0.185.1" }, "devDependencies": { + "@inlang/paraglide-js": "^2.25.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", diff --git a/project.inlang/settings.json b/project.inlang/settings.json new file mode 100644 index 0000000..08f3d17 --- /dev/null +++ b/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "zh-CN"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2.2.6/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..89e803e --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; + +const settings = JSON.parse( + readFileSync(new URL("../project.inlang/settings.json", import.meta.url)), +); +const baseMessages = readMessages(settings.baseLocale); +const baseKeys = Object.keys(baseMessages).filter((key) => key !== "$schema"); + +for (const locale of settings.locales) { + const messages = readMessages(locale); + const keys = Object.keys(messages).filter((key) => key !== "$schema"); + const missing = baseKeys.filter((key) => !(key in messages)); + const extra = keys.filter((key) => !(key in baseMessages)); + const blank = keys.filter( + (key) => typeof messages[key] !== "string" || messages[key].trim() === "", + ); + + if (missing.length || extra.length || blank.length) { + throw new Error( + [ + `${locale} catalog does not match ${settings.baseLocale}.`, + missing.length ? `Missing: ${missing.join(", ")}` : "", + extra.length ? `Extra: ${extra.join(", ")}` : "", + blank.length ? `Blank: ${blank.join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"), + ); + } +} + +function readMessages(locale) { + return JSON.parse( + readFileSync(new URL(`../messages/${locale}.json`, import.meta.url)), + ); +} + +console.log(`Validated ${settings.locales.length} localization catalogs.`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a0c6ab..528367f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ specta-typescript = { version = "0.0.9" } uuid = { version = "1", features = ["v4"] } webp = { version = "0.3.1", default-features = false } friendolls-common = { path = "../src-common" } +sys-locale = "0.3.2" [dev-dependencies] tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/migrations/20260903000000_add_locale_preference.sql b/src-tauri/migrations/20260903000000_add_locale_preference.sql new file mode 100644 index 0000000..707dc0d --- /dev/null +++ b/src-tauri/migrations/20260903000000_add_locale_preference.sql @@ -0,0 +1,2 @@ +ALTER TABLE app_settings +ADD COLUMN locale_preference TEXT NOT NULL DEFAULT 'system'; diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 44fb1b3..52d07c6 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -10,7 +10,7 @@ struct ApplicationState { pub async fn init(handle: &AppHandle) -> Result<(), String> { handle.manage(ApplicationState::default()); let accessibility_permission_granted = crate::macos::init(handle).await?; - let settings = crate::onboarding::get(&handle.state()) + let settings = crate::settings::get(&handle.state()) .await .map_err(crate::db::command_error)?; diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index fde35aa..da169d1 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -74,12 +74,16 @@ async fn database_requires_newer_app(pool: &Pool) -> Result Result<(), Box> { updater::init(handle).await; db::init(handle).await?; + settings::init(handle).await?; scene_configuration::init(handle).await?; keypair::init(handle).await?; interactions::init(handle); @@ -102,6 +104,8 @@ fn specta_builder() -> tauri_specta::Builder { onboarding::get_onboarding_status, onboarding::complete_onboarding, macos::request_accessibility_permission, + settings::get_locale_settings, + settings::set_locale_preference, ui::control_panel::open_action_window, ui::scene::update_scene_hitboxes, ]) @@ -118,6 +122,7 @@ fn specta_builder() -> tauri_specta::Builder { puppet::PuppetStatesChanged, scene_configuration::SceneConfigurationChanged, onboarding::OnboardingStatus, + settings::LocaleChanged, ]) } diff --git a/src-tauri/src/onboarding/mod.rs b/src-tauri/src/onboarding/mod.rs index c5f30ef..1e318ad 100644 --- a/src-tauri/src/onboarding/mod.rs +++ b/src-tauri/src/onboarding/mod.rs @@ -4,13 +4,7 @@ use tauri::{AppHandle, State}; use tauri_specta::Event; use crate::db::{self, AppDatabase}; - -const SETTINGS_ID: i64 = 1; - -#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub struct AppSettings { - pub onboarding_done: bool, -} +use crate::settings::AppSettings; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)] #[serde(rename_all = "camelCase")] @@ -32,30 +26,16 @@ impl OnboardingStatus { } } -pub async fn get(database: &AppDatabase) -> Result { - sqlx::query_as::<_, AppSettings>("SELECT onboarding_done FROM app_settings WHERE id = ?1") - .bind(SETTINGS_ID) - .fetch_one(database.pool()) - .await -} - -async fn set_onboarding_done( - database: &AppDatabase, - onboarding_done: bool, -) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2") - .bind(onboarding_done) - .bind(SETTINGS_ID) - .execute(database.pool()) - .await?; - Ok(()) -} - pub(crate) async fn emit_status( handle: &AppHandle, database: &AppDatabase, ) -> Result { - let status = OnboardingStatus::new(handle, get(database).await.map_err(db::command_error)?); + let status = OnboardingStatus::new( + handle, + crate::settings::get(database) + .await + .map_err(db::command_error)?, + ); status.clone().emit(handle).map_err(db::command_error)?; Ok(status) } @@ -66,7 +46,9 @@ pub async fn get_onboarding_status( handle: AppHandle, database: State<'_, AppDatabase>, ) -> Result { - let settings = get(&database).await.map_err(db::command_error)?; + let settings = crate::settings::get(&database) + .await + .map_err(db::command_error)?; Ok(OnboardingStatus::new(&handle, settings)) } @@ -80,7 +62,7 @@ pub async fn complete_onboarding( return Err("Accessibility access must be granted before setup can finish.".to_owned()); } - set_onboarding_done(&database, true) + crate::settings::set_onboarding_done(&database, true) .await .map_err(db::command_error)?; emit_status(&handle, &database).await?; @@ -111,17 +93,21 @@ mod tests { async fn onboarding_completion_is_persisted() { let database = database().await; assert_eq!( - get(&database).await.unwrap(), + crate::settings::get(&database).await.unwrap(), AppSettings { onboarding_done: false, + locale_preference: "system".to_owned(), } ); - set_onboarding_done(&database, true).await.unwrap(); + crate::settings::set_onboarding_done(&database, true) + .await + .unwrap(); assert_eq!( - get(&database).await.unwrap(), + crate::settings::get(&database).await.unwrap(), AppSettings { onboarding_done: true, + locale_preference: "system".to_owned(), } ); } diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index e4494f0..441e853 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -23,6 +23,7 @@ pub async fn get(database: &AppDatabase, public_key: &str) -> Result Result, +} + +#[derive(Debug, Clone, Copy)] +pub enum NativeText { + OpenControlPanel, + Quit, + OutdatedTitle, + OutdatedMessage, + CloseFriendolls, + Images, + OnboardingTitle, + SceneTitle, +} + +pub async fn init(handle: &AppHandle) -> Result<(), String> { + let settings = get(&handle.state()).await.map_err(db::command_error)?; + handle.manage(SettingsState { + locale: RwLock::new(locale_settings(&settings.locale_preference)), + }); + Ok(()) +} + +pub async fn get(database: &AppDatabase) -> Result { + sqlx::query_as::<_, AppSettings>( + "SELECT onboarding_done, locale_preference FROM app_settings WHERE id = ?1", + ) + .bind(SETTINGS_ID) + .fetch_one(database.pool()) + .await +} + +pub async fn set_onboarding_done( + database: &AppDatabase, + onboarding_done: bool, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2") + .bind(onboarding_done) + .bind(SETTINGS_ID) + .execute(database.pool()) + .await?; + Ok(()) +} + +fn locale_settings(preference: &str) -> LocaleChanged { + let locale = if preference == SYSTEM_LOCALE_PREFERENCE { + resolve_locale(sys_locale::get_locale().as_deref()) + } else { + resolve_locale(Some(preference)) + }; + LocaleChanged { + preference: preference.to_owned(), + locale, + } +} + +fn resolve_locale(requested: Option<&str>) -> String { + let requested = requested.unwrap_or_default().replace('_', "-"); + if let Some(locale) = SUPPORTED_LOCALES + .iter() + .find(|locale| locale.eq_ignore_ascii_case(&requested)) + { + return (*locale).to_owned(); + } + + let requested_language = requested.split('-').next().unwrap_or_default(); + SUPPORTED_LOCALES + .iter() + .find(|locale| { + locale + .split('-') + .next() + .is_some_and(|language| language.eq_ignore_ascii_case(requested_language)) + }) + .copied() + .unwrap_or(BASE_LOCALE) + .to_owned() +} + +fn is_supported_preference(preference: &str) -> bool { + preference == SYSTEM_LOCALE_PREFERENCE || SUPPORTED_LOCALES.contains(&preference) +} + +pub fn current(handle: &AppHandle) -> LocaleChanged { + handle + .state::() + .locale + .read() + .unwrap_or_else(|error| error.into_inner()) + .clone() +} + +pub fn text(handle: &AppHandle, key: NativeText) -> &'static str { + localized_text(¤t(handle).locale, key) +} + +pub fn system_text(key: NativeText) -> &'static str { + localized_text(&resolve_locale(sys_locale::get_locale().as_deref()), key) +} + +fn localized_text(locale: &str, key: NativeText) -> &'static str { + match (locale, key) { + ("zh-CN", NativeText::OpenControlPanel) => "打开控制面板", + ("zh-CN", NativeText::Quit) => "退出", + ("zh-CN", NativeText::OutdatedTitle) => "Friendolls 版本过旧", + ("zh-CN", NativeText::OutdatedMessage) => { + "此设备上的数据由较新版本的 Friendolls 创建,当前版本无法安全地打开这些数据。\n\n请下载并安装最新版 Friendolls,然后重新打开应用。你的数据未被更改。" + } + ("zh-CN", NativeText::CloseFriendolls) => "关闭 Friendolls", + ("zh-CN", NativeText::Images) => "图片", + ("zh-CN", NativeText::OnboardingTitle) => "Friendolls 安装向导", + ("zh-CN", NativeText::SceneTitle) => "场景", + (_, NativeText::OpenControlPanel) => "Open Control Panel", + (_, NativeText::Quit) => "Quit", + (_, NativeText::OutdatedTitle) => "Friendolls is out of date", + (_, NativeText::OutdatedMessage) => { + "Your on-device data was created by a newer version of Friendolls and cannot be opened safely by this version.\n\nDownload and install the latest version of Friendolls, then reopen the app. Your data has not been changed." + } + (_, NativeText::CloseFriendolls) => "Close Friendolls", + (_, NativeText::Images) => "Images", + (_, NativeText::OnboardingTitle) => "Friendolls Setup", + (_, NativeText::SceneTitle) => "Scene", + } +} + +#[tauri::command] +#[specta::specta] +pub fn get_locale_settings(handle: AppHandle) -> LocaleChanged { + current(&handle) +} + +#[tauri::command] +#[specta::specta] +pub async fn set_locale_preference( + handle: AppHandle, + database: State<'_, AppDatabase>, + preference: String, +) -> Result { + if !is_supported_preference(&preference) { + return Err("Unsupported locale preference".to_owned()); + } + + sqlx::query("UPDATE app_settings SET locale_preference = ?1 WHERE id = ?2") + .bind(&preference) + .bind(SETTINGS_ID) + .execute(database.pool()) + .await + .map_err(db::command_error)?; + + let next = locale_settings(&preference); + *handle + .state::() + .locale + .write() + .unwrap_or_else(|error| error.into_inner()) = next.clone(); + next.clone().emit(&handle).map_err(db::command_error)?; + crate::ui::refresh_locale(&handle); + Ok(next) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_exact_and_language_only_locales() { + assert_eq!(resolve_locale(Some("zh-CN")), "zh-CN"); + assert_eq!(resolve_locale(Some("zh_SG")), "zh-CN"); + assert_eq!(resolve_locale(Some("en-GB")), "en"); + } + + #[test] + fn unsupported_locales_fall_back_to_english() { + assert_eq!(resolve_locale(Some("ja-JP")), "en"); + assert_eq!(resolve_locale(None), "en"); + } +} diff --git a/src-tauri/src/ui/mod.rs b/src-tauri/src/ui/mod.rs index 19c576b..78b752d 100644 --- a/src-tauri/src/ui/mod.rs +++ b/src-tauri/src/ui/mod.rs @@ -15,3 +15,7 @@ pub fn init(app_handle: &AppHandle) { control_panel::init(app_handle); tray::init(app_handle); } + +pub fn refresh_locale(app_handle: &AppHandle) { + tray::refresh_locale(app_handle); +} diff --git a/src-tauri/src/ui/onboarding.rs b/src-tauri/src/ui/onboarding.rs index 73a4764..59ad5e3 100644 --- a/src-tauri/src/ui/onboarding.rs +++ b/src-tauri/src/ui/onboarding.rs @@ -21,7 +21,10 @@ fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String> { }; let window = tauri::WebviewWindowBuilder::new(handle, WINDOW_LABEL, WebviewUrl::App(page.into())) - .title("Friendolls Setup") + .title(crate::settings::text( + handle, + crate::settings::NativeText::OnboardingTitle, + )) .inner_size(680.0, 520.0) .min_inner_size(680.0, 520.0) .resizable(false) diff --git a/src-tauri/src/ui/scene/mod.rs b/src-tauri/src/ui/scene/mod.rs index b019756..9ac2d22 100644 --- a/src-tauri/src/ui/scene/mod.rs +++ b/src-tauri/src/ui/scene/mod.rs @@ -160,7 +160,10 @@ pub fn open_window(app_handle: &AppHandle) -> Result, + quit: MenuItem, +} + pub fn init(handle: &AppHandle) { let open_control_panel = MenuItem::with_id( handle, OPEN_CONTROL_PANEL_ID, - "Open Control Panel", + crate::settings::text(handle, crate::settings::NativeText::OpenControlPanel), true, None::<&str>, ) .expect("Open Control Panel tray menu item should be created"); - let quit = MenuItem::with_id(handle, QUIT_ID, "Quit", true, None::<&str>) - .expect("Quit tray menu item should be created"); + let quit = MenuItem::with_id( + handle, + QUIT_ID, + crate::settings::text(handle, crate::settings::NativeText::Quit), + true, + None::<&str>, + ) + .expect("Quit tray menu item should be created"); let menu = Menu::with_items(handle, &[&open_control_panel, &quit]) .expect("Tray menu should be created"); @@ -42,4 +53,22 @@ pub fn init(handle: &AppHandle) { } builder.build(handle).expect("Tray icon should be created"); + handle.manage(TrayMenuState { + open_control_panel, + quit, + }); +} + +pub fn refresh_locale(handle: &AppHandle) { + let Some(items) = handle.try_state::() else { + return; + }; + let _ = items.open_control_panel.set_text(crate::settings::text( + handle, + crate::settings::NativeText::OpenControlPanel, + )); + let _ = items.quit.set_text(crate::settings::text( + handle, + crate::settings::NativeText::Quit, + )); } diff --git a/src-tauri/src/user/mod.rs b/src-tauri/src/user/mod.rs index 903d67f..cfb3e9c 100644 --- a/src-tauri/src/user/mod.rs +++ b/src-tauri/src/user/mod.rs @@ -11,6 +11,7 @@ use specta::Type; pub struct User { pub id: String, pub display_name: String, + pub display_name_configured: bool, pub skin_hash: Option, } diff --git a/src/app.css b/src/app.css index 4eb6a79..981de40 100644 --- a/src/app.css +++ b/src/app.css @@ -87,14 +87,26 @@ input { } @font-face { - font-family: "W95F"; + font-family: "w95f"; src: url("/w95f.woff2") format("woff2"); font-weight: 400; font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "ark-pixel-12px-proportional-zh_cn"; + src: url("/ark-pixel-12px-proportional-zh_cn.ttf.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; } @theme { - --font-custom: "W95F", sans-serif; + --font-en: "w95f", "Cascadia Code"; + --font-zh-cn: + "w95f", "ark-pixel-12px-proportional-zh_cn", "PingFang SC", + "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; } * { diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 6ebb6ae..191f050 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -89,6 +89,12 @@ async completeOnboarding() : Promise { async requestAccessibilityPermission() : Promise { return await TAURI_INVOKE("request_accessibility_permission"); }, +async getLocaleSettings() : Promise { + return await TAURI_INVOKE("get_locale_settings"); +}, +async setLocalePreference(preference: string) : Promise { + return await TAURI_INVOKE("set_locale_preference", { preference }); +}, async openActionWindow(name: string, title: string, pageUrl: string) : Promise { return await TAURI_INVOKE("open_action_window", { name, title, pageUrl }); }, @@ -107,6 +113,7 @@ friendForegroundAppChanged: FriendForegroundAppChanged, friendInteractionReceived: FriendInteractionReceived, friendStatusesChanged: FriendStatusesChanged, friendsChanged: FriendsChanged, +localeChanged: LocaleChanged, networkStatusChanged: NetworkStatusChanged, onboardingStatus: OnboardingStatus, profileChanged: ProfileChanged, @@ -120,6 +127,7 @@ friendForegroundAppChanged: "friend-foreground-app-changed", friendInteractionReceived: "friend-interaction-received", friendStatusesChanged: "friend-statuses-changed", friendsChanged: "friends-changed", +localeChanged: "locale-changed", networkStatusChanged: "network-status-changed", onboardingStatus: "onboarding-status", profileChanged: "profile-changed", @@ -163,6 +171,7 @@ export type FriendStatusesChanged = { friendIds: string[] } export type FriendsChanged = { friends: Friend[] } export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string } export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> } +export type LocaleChanged = { preference: string; locale: string } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean } export type ProfileChanged = { profile: User } @@ -179,7 +188,7 @@ export type SceneHitbox = { x: number; y: number; width: number; height: number * Public user identity exchanged with peers and remote servers. `id` is the * user's Ed25519 public key and is not persisted as local profile metadata. */ -export type User = { id: string; displayName: string; skinHash: string | null } +export type User = { id: string; displayName: string; displayNameConfigured: boolean; skinHash: string | null } /** tauri-specta globals **/ diff --git a/src/lib/components/control-panel/friends-panel.svelte b/src/lib/components/control-panel/friends-panel.svelte index b24c7ad..1477ac4 100644 --- a/src/lib/components/control-panel/friends-panel.svelte +++ b/src/lib/components/control-panel/friends-panel.svelte @@ -13,6 +13,7 @@ import PanelMessage from "./panel-message.svelte"; import type { RegisterPanel } from "./types"; import Info from "$lib/icons/info.svelte"; + import { errorMessage, messages } from "$lib/i18n"; let { register }: { register: RegisterPanel } = $props(); @@ -44,11 +45,11 @@ try { await commands.openActionWindow( "friend", - "Add Friend", + $messages.action_add_friend_title(), "/control-panel/add/friend", ); } catch (cause) { - error = String(cause); + error = errorMessage(cause); } finally { busy = false; } @@ -57,7 +58,7 @@ async function apply() { if (mode === "browse") return true; if (mode === "remove" && !selected) { - error = "The selected friend no longer exists."; + error = $messages.error_friend_missing(); return false; } @@ -71,7 +72,7 @@ reset(); return true; } catch (cause) { - error = String(cause); + error = errorMessage(cause); return false; } finally { busy = false; @@ -82,20 +83,20 @@
{#if error || $friendsListenerError || $friendStatusesListenerError} {error || - $friendsListenerError || - $friendStatusesListenerError}{errorMessage( + error || $friendsListenerError || $friendStatusesListenerError, + )} {/if} {#if mode === "remove" && selected} - Applying will remove {friendName(selected, "Unknown friend")}. They will immediately go offline and stop receiving your activity. + {$messages.friends_remove_prefix()}{friendName(selected, $messages.common_unknown_friend())}{$messages.friends_remove_suffix()} {$messages.friends_keep()} {:else}
- NameStatus + {$messages.common_name()}{$messages.common_status()}
{#if $friends.length === 0}
-

No friends yet

+

{$messages.friends_empty()}

- Add someone using their public key. + {$messages.friends_empty_help()}

{:else} @@ -136,12 +139,15 @@ {friendName(friend, "Unknown friend")}{friendName( + friend, + $messages.common_unknown_friend(), + )} {#if !friend.displayName}
@@ -149,14 +155,16 @@
{/if}
- {friend.id} - {online ? "Online" : "Offline"} + {online + ? $messages.common_online() + : $messages.common_offline()} {/each} @@ -169,13 +177,13 @@ class="btn" type="button" disabled={busy} - onclick={openAddActionWindow}>Add…{$messages.common_add()} (mode = "remove")}>{$messages.common_remove()}
{/if} diff --git a/src/lib/components/control-panel/general-panel.svelte b/src/lib/components/control-panel/general-panel.svelte index 6e4c0c8..3fdbc81 100644 --- a/src/lib/components/control-panel/general-panel.svelte +++ b/src/lib/components/control-panel/general-panel.svelte @@ -5,6 +5,13 @@ import PanelMessage from "./panel-message.svelte"; import type { RegisterPanel } from "./types"; import Info from "$lib/icons/info.svelte"; + import { + errorMessage, + languageOptions, + localePreference, + messages, + setLanguagePreference, + } from "$lib/i18n"; let { register }: { register: RegisterPanel } = $props(); @@ -12,6 +19,21 @@ let dirty = $state(false); let busy = $state(false); let error = $state(""); + let languageBusy = $state(false); + + async function changeLanguage(event: Event) { + languageBusy = true; + error = ""; + try { + await setLanguagePreference( + (event.currentTarget as HTMLSelectElement).value, + ); + } catch (cause) { + error = errorMessage(cause); + } finally { + languageBusy = false; + } + } $effect(() => { if (!dirty) displayName = $profile?.displayName ?? ""; @@ -35,7 +57,7 @@ const nextName = displayName.trim(); if (!dirty) return true; if (!nextName) { - error = "Display name cannot be empty."; + error = $messages.error_display_name_empty(); return false; } @@ -47,7 +69,7 @@ dirty = false; return true; } catch (cause) { - error = String(cause); + error = errorMessage(cause); return false; } finally { busy = false; @@ -57,13 +79,17 @@
{#if error || $profileListenerError} - {error || $profileListenerError} + {errorMessage(error || $profileListenerError)} {/if}
- Identity + {$messages.account_identity()} - +

- This name can be changed at any time. + {$messages.account_name_help()}

+ +
+ {$messages.account_ui_label()} + + +
diff --git a/src/lib/components/control-panel/network-panel.svelte b/src/lib/components/control-panel/network-panel.svelte index 6a1cace..5e97ac0 100644 --- a/src/lib/components/control-panel/network-panel.svelte +++ b/src/lib/components/control-panel/network-panel.svelte @@ -8,6 +8,7 @@ import { onMount } from "svelte"; import PanelMessage from "./panel-message.svelte"; import type { RegisterPanel } from "./types"; + import { errorMessage, messages } from "$lib/i18n"; let { register }: { register: RegisterPanel } = $props(); @@ -41,11 +42,11 @@ try { await commands.openActionWindow( "network", - "Add Server", + $messages.action_add_server_title(), "/control-panel/add/network", ); } catch (cause) { - error = String(cause); + error = errorMessage(cause); } finally { busy = false; } @@ -57,11 +58,11 @@ try { await commands.openActionWindow( `network-edit-${id}`, - "Edit Server", + $messages.action_edit_server_title(), `/control-panel/edit/network/${encodeURIComponent(id)}`, ); } catch (cause) { - error = String(cause); + error = errorMessage(cause); } finally { busy = false; } @@ -79,7 +80,7 @@ try { await commands.reorderRemotes(ordered.map((remote) => remote.id)); } catch (cause) { - error = String(cause); + error = errorMessage(cause); } finally { busy = false; } @@ -93,7 +94,7 @@ async function apply() { if (mode === "browse") return true; if (mode === "remove" && !selected) { - error = "The selected server no longer exists."; + error = $messages.error_server_missing(); return false; } @@ -107,7 +108,7 @@ reset(); return true; } catch (cause) { - error = String(cause); + error = errorMessage(cause); return false; } finally { busy = false; @@ -118,19 +119,20 @@
{#if error || $remotesListenerError || $connectionStatusesListenerError} {error || - $remotesListenerError || - $connectionStatusesListenerError}{errorMessage( + error || $remotesListenerError || $connectionStatusesListenerError, + )} {/if} {#if mode === "remove" && selected} - Applying will remove {selected.name ?? selected.address}. - Existing connections through this server may stop. + {$messages.network_remove_prefix()}{selected.name ?? selected.address}{$messages.network_existing_connections_stop()} {$messages.network_keep()} {:else}
- ServerConnectionPriority + {$messages.network_server()}{$messages.network_connection()}{$messages.network_priority()}
{#if $remotes.length === 0}
-

No servers configured

+

{$messages.network_no_servers()}

- Add a server to connect with friends. + {$messages.network_empty_help()}

{:else} @@ -185,22 +189,26 @@ class:status-warning={state === "connecting"} class="status shadow-none" > - {state} + {state === "connected" + ? $messages.network_connected() + : state === "connecting" + ? $messages.network_connecting() + : $messages.network_disconnected()} @@ -216,20 +224,20 @@ class="btn" type="button" disabled={busy} - onclick={openAddActionWindow}>Add…{$messages.common_add()}
selected && openEditWindow(selected.id)}>{$messages.common_edit()} (mode = "remove")}>{$messages.common_remove()}
diff --git a/src/lib/components/control-panel/scene-panel.svelte b/src/lib/components/control-panel/scene-panel.svelte index e32c543..5c72b1d 100644 --- a/src/lib/components/control-panel/scene-panel.svelte +++ b/src/lib/components/control-panel/scene-panel.svelte @@ -9,6 +9,7 @@ import PuppetPreview from "../../../routes/scene/components/renderer/puppet/preview.svelte"; import PanelMessage from "./panel-message.svelte"; import type { RegisterPanel } from "./types"; + import { errorMessage, messages } from "$lib/i18n"; let { register }: { register: RegisterPanel } = $props(); @@ -114,7 +115,7 @@ } catch (cause) { dirty = hasConfigurationChanges() || skinFile !== null || pendingSkinReset; - error = String(cause); + error = errorMessage(cause); return false; } finally { busy = false; @@ -125,7 +126,9 @@
{#if error || $sceneConfigurationListenerError || $profileListenerError} - {error || $sceneConfigurationListenerError || $profileListenerError} + {errorMessage( + error || $sceneConfigurationListenerError || $profileListenerError, + )} {/if} @@ -142,8 +145,8 @@ class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1" >
-

Scale

-

Opacity

+

{$messages.scene_scale()}

+

{$messages.scene_opacity()}

{(puppetScale * 100).toFixed(0)}%

@@ -164,7 +167,7 @@ {:else}
{/if}
@@ -172,9 +175,13 @@
- All puppets + {$messages.scene_all_puppets()}
- +
- + skinInput?.click()} > - Choose a skin + {$messages.scene_choose_skin()}
- Puppets reposition + {$messages.puppet_movement()}
diff --git a/src/routes/control-panel/add/friend/+page.svelte b/src/routes/control-panel/add/friend/+page.svelte index 96020d8..2665bbb 100644 --- a/src/routes/control-panel/add/friend/+page.svelte +++ b/src/routes/control-panel/add/friend/+page.svelte @@ -4,6 +4,7 @@ import { commands } from "$lib/bindings"; import PanelMessage from "$lib/components/control-panel/panel-message.svelte"; import Info from "$lib/icons/info.svelte"; + import { errorMessage, messages } from "$lib/i18n"; let friendId = $state(""); let busy = $state(false); @@ -15,6 +16,10 @@ let lookupTimer: ReturnType | undefined; let lookupGeneration = 0; + $effect(() => { + void getCurrentWindow().setTitle($messages.action_add_friend_title()); + }); + onDestroy(() => { lookupGeneration += 1; if (lookupTimer) clearTimeout(lookupTimer); @@ -28,7 +33,7 @@ event.preventDefault(); const id = friendId.trim(); if (!id) { - error = "Identification key is required."; + error = $messages.error_friend_key_required(); return; } @@ -38,7 +43,7 @@ await commands.createFriend(id); await closeWindow(); } catch (cause) { - error = String(cause); + error = errorMessage(cause); busy = false; } } @@ -52,7 +57,7 @@ return; } - preview = { status: "loading", text: "Resolving…" }; + preview = { status: "loading", text: $messages.common_resolving() }; lookupTimer = setTimeout( () => void resolveProfilePreview(id, generation), 250, @@ -65,16 +70,19 @@ if (generation !== lookupGeneration) return; preview = displayName ? { status: "resolved", text: displayName } - : { status: "unresolved", text: "Unresolved user" }; + : { status: "unresolved", text: $messages.common_unresolved_user() }; } catch { if (generation === lookupGeneration) { - preview = { status: "unavailable", text: "Unresolved" }; + preview = { + status: "unavailable", + text: $messages.common_unresolved(), + }; } } } -Add Friend +{$messages.action_add_friend_title()} event.key === "Escape" && !busy && void closeWindow()} /> @@ -87,16 +95,18 @@ {#if error}{error}{/if} - Their display name will appear after Friendolls learns their profile from - a shared server. + {$messages.action_friend_profile_help()}
- Friend identity + {$messages.common_friend_identity()}