chinese localization!!

This commit is contained in:
2026-09-03 16:21:01 +08:00
parent 34f35ef208
commit b731ec50d0
49 changed files with 1404 additions and 295 deletions
Generated
+10
View File
@@ -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"
+43
View File
@@ -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=="],
+191
View File
@@ -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"
}
+191
View File
@@ -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": "关闭图片"
}
+3 -1
View File
@@ -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",
+12
View File
@@ -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"
}
}
+38
View File
@@ -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.`);
+1
View File
@@ -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"] }
@@ -0,0 +1,2 @@
ALTER TABLE app_settings
ADD COLUMN locale_preference TEXT NOT NULL DEFAULT 'system';
+1 -1
View File
@@ -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)?;
+9 -5
View File
@@ -74,12 +74,16 @@ async fn database_requires_newer_app(pool: &Pool<Sqlite>) -> Result<bool, sqlx::
fn show_outdated_app_dialog(app: &AppHandle) {
app.dialog()
.message(
"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.",
)
.title("Friendolls is out of date")
.message(crate::settings::system_text(
crate::settings::NativeText::OutdatedMessage,
))
.title(crate::settings::system_text(
crate::settings::NativeText::OutdatedTitle,
))
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCustom("Close Friendolls".into()))
.buttons(MessageDialogButtons::OkCustom(
crate::settings::system_text(crate::settings::NativeText::CloseFriendolls).into(),
))
.show(move |_| std::process::exit(0));
}
+4 -1
View File
@@ -33,7 +33,10 @@ pub async fn pick_and_send_image(
let Some(file) = app
.dialog()
.file()
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"])
.add_filter(
crate::settings::text(&app, crate::settings::NativeText::Images),
&["png", "jpg", "jpeg", "gif", "webp"],
)
.blocking_pick_file()
else {
return Ok(false);
+5
View File
@@ -13,6 +13,7 @@ mod profile;
mod puppet;
mod remotes;
mod scene_configuration;
mod settings;
mod skins;
mod ufa;
mod ui;
@@ -22,6 +23,7 @@ mod user;
async fn launch_app(handle: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
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<tauri::Wry> {
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<tauri::Wry> {
puppet::PuppetStatesChanged,
scene_configuration::SceneConfigurationChanged,
onboarding::OnboardingStatus,
settings::LocaleChanged,
])
}
+18 -32
View File
@@ -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<AppSettings, sqlx::Error> {
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<OnboardingStatus, String> {
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<OnboardingStatus, String> {
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(),
}
);
}
+5
View File
@@ -23,6 +23,7 @@ pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx:
.fetch_one(database.pool())
.await?;
let display_name_configured = !display_name.is_empty();
Ok(User {
id: public_key.to_owned(),
display_name: if display_name.is_empty() {
@@ -30,6 +31,7 @@ pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx:
} else {
display_name
},
display_name_configured,
skin_hash,
})
}
@@ -151,6 +153,7 @@ mod tests {
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
@@ -159,6 +162,7 @@ mod tests {
User {
id: "rotated-public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
@@ -209,6 +213,7 @@ mod tests {
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
+205
View File
@@ -0,0 +1,205 @@
use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager, State};
use tauri_specta::Event;
use crate::db::{self, AppDatabase};
const SETTINGS_ID: i64 = 1;
pub const SYSTEM_LOCALE_PREFERENCE: &str = "system";
pub const BASE_LOCALE: &str = "en";
pub const SUPPORTED_LOCALES: &[&str] = &["en", "zh-CN"];
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct AppSettings {
pub onboarding_done: bool,
pub locale_preference: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct LocaleChanged {
pub preference: String,
pub locale: String,
}
pub struct SettingsState {
locale: RwLock<LocaleChanged>,
}
#[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<AppSettings, sqlx::Error> {
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::<SettingsState>()
.locale
.read()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
pub fn text(handle: &AppHandle, key: NativeText) -> &'static str {
localized_text(&current(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<LocaleChanged, String> {
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::<SettingsState>()
.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");
}
}
+4
View File
@@ -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);
}
+4 -1
View File
@@ -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)
+4 -1
View File
@@ -160,7 +160,10 @@ pub fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, Strin
WINDOW_LABEL,
WebviewUrl::App("/scene".into()),
)
.title("Scene")
.title(crate::settings::text(
app_handle,
crate::settings::NativeText::SceneTitle,
))
.inner_size(800.0, 600.0)
.resizable(false)
.maximizable(false)
+32 -3
View File
@@ -1,5 +1,5 @@
use tauri::{
AppHandle,
AppHandle, Manager, Wry,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
};
@@ -9,16 +9,27 @@ use super::control_panel;
const OPEN_CONTROL_PANEL_ID: &str = "open-control-panel";
const QUIT_ID: &str = "quit";
struct TrayMenuState {
open_control_panel: MenuItem<Wry>,
quit: MenuItem<Wry>,
}
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>)
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::<TrayMenuState>() 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,
));
}
+1
View File
@@ -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<String>,
}
+14 -2
View File
@@ -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;
}
* {
+10 -1
View File
@@ -89,6 +89,12 @@ async completeOnboarding() : Promise<null> {
async requestAccessibilityPermission() : Promise<boolean> {
return await TAURI_INVOKE("request_accessibility_permission");
},
async getLocaleSettings() : Promise<LocaleChanged> {
return await TAURI_INVOKE("get_locale_settings");
},
async setLocalePreference(preference: string) : Promise<LocaleChanged> {
return await TAURI_INVOKE("set_locale_preference", { preference });
},
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
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 **/
@@ -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 @@
<div class="flex h-full min-h-0 flex-col gap-2">
{#if error || $friendsListenerError || $friendStatusesListenerError}
<PanelMessage kind="error"
>{error ||
$friendsListenerError ||
$friendStatusesListenerError}</PanelMessage
>{errorMessage(
error || $friendsListenerError || $friendStatusesListenerError,
)}</PanelMessage
>
{/if}
{#if mode === "remove" && selected}
<PanelMessage kind="warning">
Applying will remove <strong
>{friendName(selected, "Unknown friend")}</strong
>. They will immediately go offline and stop receiving your activity.
{$messages.friends_remove_prefix()}<strong
>{friendName(selected, $messages.common_unknown_friend())}</strong
>{$messages.friends_remove_suffix()}
</PanelMessage>
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
>Keep friend</button
>{$messages.friends_keep()}</button
>
{:else}
<div
@@ -104,20 +105,22 @@
<div
class="grid grid-cols-[1fr_4.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
>
<span>Name</span><span>Status</span>
<span>{$messages.common_name()}</span><span
>{$messages.common_status()}</span
>
</div>
<div
class="min-h-0 flex-1 overflow-y-auto"
role="listbox"
aria-label="Friends"
aria-label={$messages.friends_list_label()}
>
{#if $friends.length === 0}
<div
class="grid h-full min-h-36 place-content-center px-8 text-center"
>
<p class="text-xs font-bold">No friends yet</p>
<p class="text-xs font-bold">{$messages.friends_empty()}</p>
<p class="mt-1 text-xs text-base-content/60">
Add someone using their public key.
{$messages.friends_empty_help()}
</p>
</div>
{:else}
@@ -136,12 +139,15 @@
<strong
class:italic={!friend.displayName}
class="block truncate"
>{friendName(friend, "Unknown friend")}</strong
>{friendName(
friend,
$messages.common_unknown_friend(),
)}</strong
>
{#if !friend.displayName}
<div
class="tooltip tooltip-primary"
data-tip={"Will be resolved when they're online"}
data-tip={$messages.friends_resolve_online()}
>
<div class="*:size-3 opacity-50">
<Info />
@@ -149,14 +155,16 @@
</div>
{/if}
</div>
<span class="block truncate font-mono text-[9px] opacity-65"
<span class="block truncate text-[9px] opacity-65"
>{friend.id}</span
>
</span>
<span class="flex items-center gap-1.5">
<span class:status-success={online} class="status shadow-none"
></span>
{online ? "Online" : "Offline"}
{online
? $messages.common_online()
: $messages.common_offline()}
</span>
</button>
{/each}
@@ -169,13 +177,13 @@
class="btn"
type="button"
disabled={busy}
onclick={openAddActionWindow}>Add…</button
onclick={openAddActionWindow}>{$messages.common_add()}</button
>
<button
class="btn"
type="button"
disabled={!selected}
onclick={() => (mode = "remove")}>Remove</button
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
>
</div>
{/if}
@@ -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 @@
<div class="space-y-3">
{#if error || $profileListenerError}
<PanelMessage kind="error">{error || $profileListenerError}</PanelMessage>
<PanelMessage kind="error"
>{errorMessage(error || $profileListenerError)}</PanelMessage
>
{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Identity</legend>
<legend class="fieldset-legend px-1">{$messages.account_identity()}</legend>
<label class="fieldset-label" for="display-name">Display name</label>
<label class="fieldset-label" for="display-name"
>{$messages.common_display_name()}</label
>
<input
id="display-name"
class="input w-full"
@@ -75,14 +101,14 @@
aria-describedby="display-name-help"
/>
<p id="display-name-help" class="fieldset-label">
This name can be changed at any time.
{$messages.account_name_help()}
</p>
<label class="fieldset-label mt-2" for="public-key"
>Identification Key
>{$messages.common_identification_key()}
<div
class="tooltip tooltip-primary"
data-tip="Share only with people you trust."
data-tip={$messages.account_key_trust()}
>
<div class="*:size-3">
<Info />
@@ -93,27 +119,48 @@
<div class="join w-full">
<input
id="public-key"
class="input join-item min-w-0 flex-1 font-mono text-[10px]"
class="input join-item min-w-0 flex-1 text-[10px]"
value={$profile.id}
readonly
aria-label="Public key"
aria-label={$messages.account_public_key()}
/>
<button
class="btn join-item"
type="button"
onclick={() => navigator.clipboard.writeText($profile?.id ?? "")}
title="Copy public key">Copy</button
title={$messages.account_copy_key()}>{$messages.common_copy()}</button
>
</div>
{:else if $profileListenerError}
<input
id="public-key"
class="input w-full"
value="Unavailable"
value={$messages.common_unavailable()}
disabled
/>
{:else}
<div class="skeleton h-7 w-full" aria-label="Loading public key"></div>
<div
class="skeleton h-7 w-full"
aria-label={$messages.account_loading_key()}
></div>
{/if}
</fieldset>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">{$messages.account_ui_label()}</legend>
<label class="fieldset-label" for="language"
>{$messages.language_label()}</label
>
<select
id="language"
class="select w-full"
value={$localePreference}
disabled={languageBusy}
onchange={changeLanguage}
>
{#each languageOptions as option (option.value)}
<option value={option.value}>{option.label($messages)}</option>
{/each}
</select>
</fieldset>
</div>
@@ -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 @@
<div class="flex h-full min-h-0 flex-col gap-2">
{#if error || $remotesListenerError || $connectionStatusesListenerError}
<PanelMessage kind="error"
>{error ||
$remotesListenerError ||
$connectionStatusesListenerError}</PanelMessage
>{errorMessage(
error || $remotesListenerError || $connectionStatusesListenerError,
)}</PanelMessage
>
{/if}
{#if mode === "remove" && selected}
<PanelMessage kind="warning">
Applying will remove <strong>{selected.name ?? selected.address}</strong>.
Existing connections through this server may stop.
{$messages.network_remove_prefix()}<strong
>{selected.name ?? selected.address}</strong
>{$messages.network_existing_connections_stop()}
</PanelMessage>
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
>Keep server</button
>{$messages.network_keep()}</button
>
{:else}
<div
@@ -139,20 +141,22 @@
<div
class="grid grid-cols-[1fr_5.5rem_3.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
>
<span>Server</span><span>Connection</span><span>Priority</span>
<span>{$messages.network_server()}</span><span
>{$messages.network_connection()}</span
><span>{$messages.network_priority()}</span>
</div>
<div
class="min-h-0 flex-1 overflow-y-auto"
role="group"
aria-label="Configured servers"
aria-label={$messages.network_configured_servers()}
>
{#if $remotes.length === 0}
<div
class="grid h-full min-h-36 place-content-center px-8 text-center"
>
<p class="text-xs font-bold">No servers configured</p>
<p class="text-xs font-bold">{$messages.network_no_servers()}</p>
<p class="mt-1 text-xs text-base-content/60">
Add a server to connect with friends.
{$messages.network_empty_help()}
</p>
</div>
{:else}
@@ -185,22 +189,26 @@
class:status-warning={state === "connecting"}
class="status shadow-none"
></span>
{state}
{state === "connected"
? $messages.network_connected()
: state === "connecting"
? $messages.network_connecting()
: $messages.network_disconnected()}
</span>
<span class="flex justify-end gap-0.5">
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Increase priority for ${remote.name ?? remote.address}`}
title="Move up"
aria-label={`${$messages.network_move_up()}: ${remote.name ?? remote.address}`}
title={$messages.network_move_up()}
disabled={busy || index === 0}
onclick={() => moveRemote(remote.id, -1)}>↑</button
>
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Decrease priority for ${remote.name ?? remote.address}`}
title="Move down"
aria-label={`${$messages.network_move_down()}: ${remote.name ?? remote.address}`}
title={$messages.network_move_down()}
disabled={busy || index === $remotes.length - 1}
onclick={() => moveRemote(remote.id, 1)}>↓</button
>
@@ -216,20 +224,20 @@
class="btn"
type="button"
disabled={busy}
onclick={openAddActionWindow}>Add…</button
onclick={openAddActionWindow}>{$messages.common_add()}</button
>
<div class="flex flex-row gap-1.5">
<button
class="btn join-item"
type="button"
disabled={!selected || busy}
onclick={() => selected && openEditWindow(selected.id)}>Edit…</button
onclick={() => selected && openEditWindow(selected.id)}>{$messages.common_edit()}</button
>
<button
class="btn join-item"
type="button"
disabled={!selected}
onclick={() => (mode = "remove")}>Remove</button
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
>
</div>
</div>
@@ -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 @@
<div class="space-y-1">
{#if error || $sceneConfigurationListenerError || $profileListenerError}
<PanelMessage kind="error">
{error || $sceneConfigurationListenerError || $profileListenerError}
{errorMessage(
error || $sceneConfigurationListenerError || $profileListenerError,
)}
</PanelMessage>
{/if}
@@ -142,8 +145,8 @@
class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1"
>
<div class="text-start flex flex-col">
<p>Scale</p>
<p>Opacity</p>
<p>{$messages.scene_scale()}</p>
<p>{$messages.scene_opacity()}</p>
</div>
<div class="text-end flex flex-col">
<p>{(puppetScale * 100).toFixed(0)}%</p>
@@ -164,7 +167,7 @@
{:else}
<div
class="skeleton aspect-square size-full"
aria-label="Loading puppet preview"
aria-label={$messages.scene_loading_preview()}
></div>
{/if}
</div>
@@ -172,9 +175,13 @@
<div class="min-w-0 space-y-2">
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
<legend class="fieldset-legend px-1">All puppets</legend>
<legend class="fieldset-legend px-1"
>{$messages.scene_all_puppets()}</legend
>
<div>
<label class="fieldset-label" for="puppet-scale">Scale</label>
<label class="fieldset-label" for="puppet-scale"
>{$messages.scene_scale()}</label
>
<input
id="puppet-scale"
class="range range-sm"
@@ -190,7 +197,9 @@
</div>
<div>
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<label class="fieldset-label" for="puppet-opacity"
>{$messages.scene_opacity()}</label
>
<input
id="puppet-opacity"
class="range range-sm"
@@ -212,7 +221,7 @@
disabled={!$profile || busy}
onclick={() => skinInput?.click()}
>
Choose a skin
{$messages.scene_choose_skin()}
</button>
<button
class="btn"
@@ -220,7 +229,7 @@
disabled={!$profile || busy || (!$profile.skinHash && !skinFile)}
onclick={resetSkin}
>
Reset
{$messages.common_reset()}
</button>
<input
id="skin-file"
@@ -235,7 +244,7 @@
</div>
</div>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
<legend class="fieldset-legend px-1">Puppets reposition</legend>
<legend class="fieldset-legend px-1">{$messages.puppet_movement()}</legend>
<div class="grid grid-cols-2 gap-2">
<label
class:border-primary={puppetMovementMode === "free"}
@@ -243,7 +252,7 @@
>
<img
src="/puppet-movement-free.svg"
alt="Puppets moving freely across the full viewport"
alt={$messages.puppet_free_alt()}
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "free"}
/>
@@ -252,7 +261,7 @@
class:bg-primary-content={puppetMovementMode === "free"}
class:text-primary={puppetMovementMode === "free"}
>
<span class="text-xs font-medium">Freeroam around</span>
<span class="text-xs font-medium">{$messages.puppet_free()}</span>
<input
class="hidden"
type="radio"
@@ -275,7 +284,7 @@
>
<img
src="/puppet-movement-bottom.svg"
alt="Puppets moving along the bottom of the viewport"
alt={$messages.puppet_bottom_alt()}
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "bottom"}
/>
@@ -283,7 +292,7 @@
class="card-body items-center gap-2 p-2 text-center"
class:bg-primary-content={puppetMovementMode === "bottom"}
>
<span class="text-xs font-medium">Stay on the ground</span>
<span class="text-xs font-medium">{$messages.puppet_bottom()}</span>
<input
class="hidden"
type="radio"
+138
View File
@@ -0,0 +1,138 @@
import { derived, writable } from "svelte/store";
import { commands, events, type LocaleChanged } from "$lib/bindings";
import * as compiledMessages from "../../paraglide/messages.js";
import {
baseLocale,
getLocale,
isLocale,
setLocale,
type Locale,
} from "../../paraglide/runtime.js";
export type MessageCatalog = typeof compiledMessages;
export const locale = writable<Locale>(baseLocale);
export const localePreference = writable("system");
// A new object is emitted for each locale so Svelte reevaluates every message
// call without requiring a provider in every Tauri window.
export const messages = derived(locale, () => ({ ...compiledMessages }));
export const languageOptions = [
{
value: "system",
label: (catalog: MessageCatalog) => catalog.language_system(),
},
{
value: "en",
label: (catalog: MessageCatalog) => catalog.language_english(),
},
{
value: "zh-CN",
label: (catalog: MessageCatalog) => catalog.language_chinese(),
},
] as const;
async function applyLocaleSettings(settings: LocaleChanged) {
const nextLocale = isLocale(settings.locale) ? settings.locale : baseLocale;
await setLocale(nextLocale, { reload: false });
localePreference.set(settings.preference);
locale.set(nextLocale);
document.documentElement.lang = nextLocale;
}
export async function initLocalization(): Promise<() => void> {
const unlisten = await events.localeChanged.listen((event) => {
void applyLocaleSettings(event.payload);
});
try {
await applyLocaleSettings(await commands.getLocaleSettings());
return unlisten;
} catch (error) {
unlisten();
const fallback = getLocale();
locale.set(fallback);
document.documentElement.lang = fallback;
throw error;
}
}
export async function setLanguagePreference(preference: string) {
await applyLocaleSettings(await commands.setLocalePreference(preference));
}
export function errorMessage(cause: unknown): string {
const value = cause instanceof Error ? cause.message : String(cause);
switch (value) {
case "Display name cannot be empty.":
return compiledMessages.error_display_name_empty();
case "Server address is required.":
return compiledMessages.error_server_address_required();
case "Port must be a whole number from 1 to 65535.":
return compiledMessages.error_port_invalid();
case "Identification key is required.":
return compiledMessages.error_friend_key_required();
case "No server was selected.":
return compiledMessages.error_server_not_selected();
case "This server no longer exists.":
return compiledMessages.error_server_missing();
case "The selected friend no longer exists.":
return compiledMessages.error_friend_missing();
case "Your profile is still loading.":
return compiledMessages.error_profile_loading();
case "Choose a 64×64 PNG skin before continuing.":
return compiledMessages.error_skin_required();
case "Turn on Friendolls in macOS Accessibility settings before continuing.":
return compiledMessages.error_accessibility_required();
case "Identification key is not valid base64url.":
return compiledMessages.error_key_invalid_base64();
case "Identification key must encode a 32-byte public key.":
return compiledMessages.error_key_invalid_length();
case "Identification key is not a valid Ed25519 public key.":
return compiledMessages.error_key_invalid_ed25519();
case "You cannot add your own identification key.":
return compiledMessages.error_own_key();
case "The selected image is not a local file":
return compiledMessages.error_image_not_local();
case "The selected image could not be read":
return compiledMessages.error_image_unreadable();
case "Choose an image file":
return compiledMessages.error_image_choose();
case "The selected image could not be decoded":
return compiledMessages.error_image_decode();
case "Image compression failed":
return compiledMessages.error_image_compression();
case "Image is still too detailed after compression":
return compiledMessages.error_image_too_detailed();
case "No connected relay can currently reach this friend":
return compiledMessages.error_friend_unreachable();
case "Friend is busy; try again":
return compiledMessages.error_friend_busy();
case "Relay rejected the interaction":
return compiledMessages.error_interaction_rejected();
case "Friend is no longer available":
return compiledMessages.error_friend_unavailable();
case "Skin must be a valid PNG image":
return compiledMessages.error_skin_invalid_png();
default:
{
const imageLimit =
/^The selected image must be at most (\d+) MiB$/u.exec(value);
if (imageLimit) {
return compiledMessages.error_image_too_large({
max: Number(imageLimit[1]),
});
}
const skinDimensions = /^Skin must be (\d+)×(\d+) pixels$/u.exec(value);
if (skinDimensions) {
return compiledMessages.error_skin_dimensions({
width: Number(skinDimensions[1]),
height: Number(skinDimensions[2]),
});
}
}
if (getLocale() !== "en" && /[\u3400-\u9fff]/u.test(value)) return value;
return getLocale() === "en" ? value : compiledMessages.error_unexpected();
}
}
+37 -3
View File
@@ -1,12 +1,42 @@
<script>
<script lang="ts">
import { browser } from "$app/environment";
import { onMount } from "svelte";
import { initLocalization, locale } from "$lib/i18n";
import { initAppListeners } from "$lib/listeners";
import "../app.css";
let { children } = $props();
let ready = $state(false);
onMount(initAppListeners);
onMount(() => {
let disposeLocalization: (() => void) | undefined;
let disposeListeners: (() => void) | undefined;
let disposed = false;
void initLocalization()
.then((dispose) => {
if (disposed) {
dispose();
return;
}
disposeLocalization = dispose;
disposeListeners = initAppListeners();
ready = true;
})
.catch((error) => {
console.error("failed to initialize localization", error);
if (!disposed) {
disposeListeners = initAppListeners();
ready = true;
}
});
return () => {
disposed = true;
disposeListeners?.();
disposeLocalization?.();
};
});
if (browser) {
document.addEventListener("contextmenu", (e) => {
@@ -15,8 +45,12 @@
}
</script>
{#if ready}
<div
class="w-screen h-screen max-w-[100vw] max-h-screen *:size-full font-custom"
class="w-screen h-screen max-w-[100vw] max-h-screen *:size-full"
class:font-en={$locale !== "zh-CN"}
class:font-zh-cn={$locale === "zh-CN"}
>
{@render children?.()}
</div>
{/if}
+17 -9
View File
@@ -10,12 +10,13 @@
} from "$lib/components/control-panel/types";
import { getVersion } from "@tauri-apps/api/app";
import { onMount } from "svelte";
import { messages } from "$lib/i18n";
const tabs = [
{ id: "scene", label: "Scene" },
{ id: "general", label: "Account" },
{ id: "friends", label: "Friends" },
{ id: "network", label: "Network" },
{ id: "scene", label: "control_tab_scene" },
{ id: "general", label: "control_tab_account" },
{ id: "friends", label: "control_tab_friends" },
{ id: "network", label: "control_tab_network" },
] as const;
type TabId = (typeof tabs)[number]["id"];
@@ -31,6 +32,10 @@
let anyBusy = $derived(tabs.some((tab) => panelStates[tab.id].busy));
let appVersion = $state("");
$effect(() => {
void getCurrentWindow().setTitle($messages.control_window_title());
});
onMount(() => {
getVersion().then((v) => {
appVersion = v;
@@ -81,7 +86,7 @@
}
</script>
<svelte:head><title>Friendolls Properties</title></svelte:head>
<svelte:head><title>{$messages.control_window_title()}</title></svelte:head>
<main
class="relative flex h-full min-h-0 flex-col overflow-hidden bg-base-100 p-2 text-base-content"
@@ -96,7 +101,7 @@
type="radio"
name="control_panel_tabs"
class="tab text-xs"
aria-label={tab.label}
aria-label={$messages[tab.label]()}
value={tab.id}
bind:group={activeTab}
/>
@@ -127,19 +132,22 @@
class="btn min-w-16"
type="button"
disabled={anyBusy}
onclick={confirm}>OK</button
onclick={confirm}>{$messages.common_ok()}</button
>
<button
class="btn min-w-16"
type="button"
disabled={anyBusy}
onclick={cancel}>Cancel</button
onclick={cancel}>{$messages.common_cancel()}</button
>
<button
class="btn min-w-16"
type="button"
disabled={!anyDirty || anyBusy}
onclick={applyAll}>{anyBusy ? "Applying…" : "Apply"}</button
onclick={applyAll}
>{anyBusy
? $messages.common_applying()
: $messages.common_apply()}</button
>
</div>
</main>
@@ -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<typeof setTimeout> | 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(),
};
}
}
}
</script>
<svelte:head><title>Add Friend</title></svelte:head>
<svelte:head><title>{$messages.action_add_friend_title()}</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
@@ -87,16 +95,18 @@
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<PanelMessage>
Their display name will appear after Friendolls learns their profile from
a shared server.
{$messages.action_friend_profile_help()}
</PanelMessage>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Friend identity</legend>
<legend class="fieldset-legend px-1"
>{$messages.common_friend_identity()}</legend
>
<label class="fieldset-label" for="friend-key"
>Identification Key <div
>{$messages.common_identification_key()}
<div
class="tooltip tooltip-primary"
data-tip="Verify with your friend before adding."
data-tip={$messages.action_friend_verify()}
>
<div class="*:size-3">
<Info />
@@ -105,13 +115,13 @@
>
<textarea
id="friend-key"
class="textarea h-20 w-full resize-none font-mono text-[10px]"
class="textarea h-20 w-full resize-none text-[10px]"
bind:value={friendId}
autocomplete="off"
oninput={scheduleProfilePreview}
required></textarea>
<label class="fieldset-label mt-1" for="friend-display-name"
>Display name</label
>{$messages.common_display_name()}</label
>
<input
id="friend-display-name"
@@ -126,13 +136,13 @@
<div class="flex shrink-0 justify-end gap-1.5 border-t border-base-300 pt-2">
<button class="btn min-w-16" type="submit" disabled={busy}
>{busy ? "Adding…" : "OK"}</button
>{busy ? $messages.action_adding() : $messages.common_ok()}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
onclick={closeWindow}>{$messages.common_cancel()}</button
>
</div>
</form>
@@ -2,6 +2,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { commands, type RemoteInput } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import { errorMessage, messages } from "$lib/i18n";
let name = $state("");
let address = $state("");
@@ -9,6 +10,10 @@
let busy = $state(false);
let error = $state("");
$effect(() => {
void getCurrentWindow().setTitle($messages.action_add_server_title());
});
async function closeWindow() {
await getCurrentWindow().close();
}
@@ -16,14 +21,14 @@
function remoteInput(): RemoteInput | null {
const parsedPort = port ? Number(port) : null;
if (!address.trim()) {
error = "Server address is required.";
error = $messages.error_server_address_required();
return null;
}
if (
parsedPort !== null &&
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
) {
error = "Port must be a whole number from 1 to 65535.";
error = $messages.error_port_invalid();
return null;
}
return {
@@ -44,13 +49,13 @@
await commands.createRemote(remote);
await closeWindow();
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
busy = false;
}
}
</script>
<svelte:head><title>Add Server</title></svelte:head>
<svelte:head><title>{$messages.action_add_server_title()}</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
@@ -63,9 +68,9 @@
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Server details</legend>
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
<label class="fieldset-label" for="remote-name"
>Friendly name (optional)</label
>{$messages.common_optional_friendly_name()}</label
>
<input
id="remote-name"
@@ -74,7 +79,7 @@
maxlength="64"
autocomplete="off"
/>
<label class="fieldset-label mt-1" for="remote-address">Address</label>
<label class="fieldset-label mt-1" for="remote-address">{$messages.common_address()}</label>
<input
id="remote-address"
class="input w-full"
@@ -84,7 +89,7 @@
required
/>
<label class="fieldset-label mt-1" for="remote-port"
>Port (optional)</label
>{$messages.common_optional_port()}</label
>
<input
id="remote-port"
@@ -101,13 +106,13 @@
<div class="flex shrink-0 justify-end gap-1.5 border-t border-base-300 pt-2">
<button class="btn min-w-16" type="submit" disabled={busy}
>{busy ? "Adding…" : "OK"}</button
>{busy ? $messages.action_adding() : $messages.common_ok()}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
onclick={closeWindow}>{$messages.common_cancel()}</button
>
</div>
</form>
@@ -4,6 +4,7 @@
import { onMount } from "svelte";
import { commands, type RemoteInput } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import { errorMessage, messages } from "$lib/i18n";
let name = $state("");
let address = $state("");
@@ -15,11 +16,15 @@
const remoteId = $derived($page.params.id ?? "");
$effect(() => {
void getCurrentWindow().setTitle($messages.action_edit_server_title());
});
onMount(() => {
let active = true;
if (!remoteId) {
error = "No server was selected.";
error = $messages.error_server_not_selected();
loading = false;
return;
}
@@ -29,7 +34,7 @@
.then((remote) => {
if (!active) return;
if (!remote) {
error = "This server no longer exists.";
error = $messages.error_server_missing();
return;
}
name = remote.name ?? "";
@@ -38,7 +43,7 @@
available = true;
})
.catch((cause) => {
if (active) error = String(cause);
if (active) error = errorMessage(cause);
})
.finally(() => {
if (active) loading = false;
@@ -56,14 +61,14 @@
function remoteInput(): RemoteInput | null {
const parsedPort = port ? Number(port) : null;
if (!address.trim()) {
error = "Server address is required.";
error = $messages.error_server_address_required();
return null;
}
if (
parsedPort !== null &&
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
) {
error = "Port must be a whole number from 1 to 65535.";
error = $messages.error_port_invalid();
return null;
}
return {
@@ -83,20 +88,20 @@
try {
const updated = await commands.updateRemote(remoteId, remote);
if (!updated) {
error = "This server no longer exists.";
error = $messages.error_server_missing();
available = false;
busy = false;
return;
}
await closeWindow();
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
busy = false;
}
}
</script>
<svelte:head><title>Edit Server</title></svelte:head>
<svelte:head><title>{$messages.action_edit_server_title()}</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
@@ -107,9 +112,9 @@
>
<div class="min-h-0 flex-1 space-y-2">
<div>
<h1 class="text-sm font-bold">Edit server</h1>
<h1 class="text-sm font-bold">{$messages.action_edit_server_heading()}</h1>
<p class="text-xs text-base-content/65">
Update how Friendolls connects to this remote server.
{$messages.action_edit_server_help()}
</p>
</div>
@@ -119,9 +124,9 @@
class="fieldset border border-base-300 bg-base-100 p-3"
disabled={loading || busy || !available}
>
<legend class="fieldset-legend px-1">Server details</legend>
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
<label class="fieldset-label" for="remote-name"
>Friendly name (optional)</label
>{$messages.common_optional_friendly_name()}</label
>
<input
id="remote-name"
@@ -130,7 +135,7 @@
maxlength="64"
autocomplete="off"
/>
<label class="fieldset-label mt-1" for="remote-address">Address</label>
<label class="fieldset-label mt-1" for="remote-address">{$messages.common_address()}</label>
<input
id="remote-address"
class="input w-full"
@@ -140,7 +145,7 @@
required
/>
<label class="fieldset-label mt-1" for="remote-port"
>Port (optional)</label
>{$messages.common_optional_port()}</label
>
<input
id="remote-port"
@@ -159,13 +164,15 @@
<button
class="btn min-w-16"
type="submit"
disabled={loading || busy || !available}>{busy ? "Saving…" : "OK"}</button
disabled={loading || busy || !available}>{busy
? $messages.common_saving()
: $messages.common_ok()}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
onclick={closeWindow}>{$messages.common_cancel()}</button
>
</div>
</form>
+52 -38
View File
@@ -19,6 +19,7 @@
import ServerStep from "./components/server-step.svelte";
import SkinStep from "./components/skin-step.svelte";
import WelcomeStep from "./components/welcome-step.svelte";
import { errorMessage, messages, type MessageCatalog } from "$lib/i18n";
type StepId =
| "welcome"
@@ -32,39 +33,48 @@
const allSteps: Array<{
id: StepId;
title: string;
title: (catalog: MessageCatalog) => string;
navigationLabel: (catalog: MessageCatalog) => string;
}> = [
{
id: "welcome",
title: "Welcome to Friendolls!",
title: (catalog) => catalog.onboarding_welcome_title(),
navigationLabel: (catalog) => catalog.onboarding_welcome_nav(),
},
{
id: "identity",
title: "Choose your Display Name",
title: (catalog) => catalog.onboarding_identity_title(),
navigationLabel: (catalog) => catalog.onboarding_identity_nav(),
},
{
id: "skin",
title: "Choose your Puppet's Skin",
title: (catalog) => catalog.onboarding_skin_title(),
navigationLabel: (catalog) => catalog.onboarding_skin_nav(),
},
{
id: "movement",
title: "Choose your Movement Mode",
title: (catalog) => catalog.onboarding_movement_title(),
navigationLabel: (catalog) => catalog.onboarding_movement_nav(),
},
{
id: "server",
title: "Add a server",
title: (catalog) => catalog.onboarding_server_title(),
navigationLabel: (catalog) => catalog.onboarding_server_nav(),
},
{
id: "friend",
title: "Add a friend",
title: (catalog) => catalog.onboarding_friend_title(),
navigationLabel: (catalog) => catalog.onboarding_friend_nav(),
},
{
id: "accessibility",
title: "Allow cursor access",
title: (catalog) => catalog.onboarding_accessibility_title(),
navigationLabel: (catalog) => catalog.onboarding_accessibility_nav(),
},
{
id: "complete",
title: "Setup is complete",
title: (catalog) => catalog.onboarding_complete_title(),
navigationLabel: (catalog) => catalog.onboarding_complete_nav(),
},
];
@@ -107,6 +117,10 @@
);
let isLastStep = $derived(stepIndex === steps.length - 1);
$effect(() => {
void getCurrentWindow().setTitle($messages.onboarding_window_title());
});
onMount(() => {
let unlisten: (() => void) | undefined;
let disposed = false;
@@ -126,10 +140,9 @@
status = nextStatus;
profile = nextProfile;
configuration = nextConfiguration;
displayName =
nextProfile.displayName === "Anonymous"
? ""
: nextProfile.displayName;
displayName = nextProfile.displayNameConfigured
? nextProfile.displayName
: "";
skinMode = nextProfile.skinHash ? "current" : "default";
movementMode = nextConfiguration.puppetMovementMode;
@@ -145,7 +158,7 @@
if (requestedIndex >= 0) stepIndex = requestedIndex;
}
} catch (cause) {
if (!disposed) error = String(cause);
if (!disposed) error = errorMessage(cause);
}
})();
@@ -174,7 +187,7 @@
port !== null &&
(!Number.isInteger(port) || port < 1 || port > 65535)
) {
throw new Error("Port must be a whole number from 1 to 65535.");
throw new Error($messages.error_port_invalid());
}
return {
address: serverAddress.trim(),
@@ -186,11 +199,11 @@
async function saveCurrentStep() {
if (step.id === "identity") {
const nextName = displayName.trim();
if (!nextName) throw new Error("Display name cannot be empty.");
if (!nextName) throw new Error($messages.error_display_name_empty());
profile = await commands.updateProfile(nextName, null);
displayName = profile.displayName;
} else if (step.id === "skin") {
if (!profile) throw new Error("Your profile is still loading.");
if (!profile) throw new Error($messages.error_profile_loading());
if (skinMode === "default" && profile.skinHash) {
profile = await commands.resetProfileSkin();
} else if (skinMode === "custom" && skinFile) {
@@ -201,7 +214,7 @@
selectSkin(null);
skinMode = "current";
} else if (skinMode === "custom") {
throw new Error("Choose a 64×64 PNG skin before continuing.");
throw new Error($messages.error_skin_required());
}
} else if (step.id === "movement") {
configuration = await commands.updateSceneConfiguration({
@@ -225,9 +238,7 @@
if (previousFriendId) await commands.deleteFriend(previousFriendId);
}
} else if (step.id === "accessibility" && !permissionGranted) {
throw new Error(
"Turn on Friendolls in macOS Accessibility settings before continuing.",
);
throw new Error($messages.error_accessibility_required());
}
}
@@ -246,7 +257,7 @@
stepIndex += 1;
}
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
@@ -265,25 +276,26 @@
try {
const granted = await commands.requestAccessibilityPermission();
if (!granted) {
error =
"macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.";
error = $messages.accessibility_settings_opened();
}
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
}
</script>
<svelte:head><title>Friendolls Setup</title></svelte:head>
<svelte:head><title>{$messages.onboarding_window_title()}</title></svelte:head>
<main class="flex h-full min-h-0 flex-col bg-base-200 text-base-content">
<div class="grid min-h-0 flex-1 grid-cols-[11rem_minmax(0,1fr)]">
<aside
class="flex flex-col bg-linear-to-b from-primary to-primary/50 p-5 text-primary-content"
>
<div class="text-lg font-bold leading-tight">Friendolls<br />Setup</div>
<div class="text-lg font-bold leading-tight">
{$messages.onboarding_brand()}
</div>
<div class="mt-6 flex-1 space-y-2 text-[11px]">
{#each steps as candidate, index (candidate.id)}
<div
@@ -298,7 +310,7 @@
>
{index < stepIndex ? "✓" : index + 1}
</span>
<span>{candidate.title.replace("Choose your ", "")}</span>
<span>{candidate.navigationLabel($messages)}</span>
</div>
{/each}
</div>
@@ -308,8 +320,7 @@
<section class="flex min-h-0 flex-col bg-base-100">
{#if step}
<header class="bg-base-100 px-6 pt-4">
<p class="font-bold text-2xl">{step.title}</p>
<!-- <p class="text-xs text-base-content/65">{step.description}</p> -->
<p class="font-bold text-2xl">{step.title($messages)}</p>
</header>
<div class="min-h-0 flex-1 overflow-y-auto p-6">
@@ -324,7 +335,7 @@
{/if}
{#if !status || !profile}
<div class="space-y-3" aria-label="Loading setup">
<div class="space-y-3" aria-label={$messages.onboarding_loading()}>
<div class="skeleton h-5 w-2/3"></div>
<div class="skeleton h-24 w-full"></div>
</div>
@@ -370,8 +381,11 @@
>
<span class="text-[10px] text-base-content/60">
{remediation
? "Permission repair"
: `Step ${stepIndex + 1} of ${steps.length}`}
? $messages.onboarding_permission_repair()
: $messages.onboarding_step_count({
current: stepIndex + 1,
total: steps.length,
})}
</span>
<div class="flex justify-end gap-2">
{#if !remediation}
@@ -379,7 +393,7 @@
class="btn min-w-20"
type="button"
disabled={busy || stepIndex === 0}
onclick={back}>&lt; Back</button
onclick={back}>{$messages.onboarding_back()}</button
>
{/if}
<button
@@ -390,12 +404,12 @@
onclick={next}
>
{busy
? "Working…"
? $messages.onboarding_working()
: remediation
? "Done"
? $messages.onboarding_done()
: isLastStep
? "Finish"
: "Next >"}
? $messages.onboarding_finish()
: $messages.onboarding_next()}
</button>
</div>
</footer>
@@ -1,5 +1,6 @@
<script lang="ts">
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import { messages } from "$lib/i18n";
let {
permissionGranted,
@@ -12,20 +13,16 @@
<div class="space-y-4">
<PanelMessage kind={permissionGranted ? "success" : "warning"}>
{permissionGranted
? "Accessibility access is granted. Puppets can now follow your cursor."
: "Accessibility access is still waiting for your approval."}
? $messages.accessibility_granted()
: $messages.accessibility_waiting()}
</PanelMessage>
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Why Friendolls needs this</legend>
<p class="text-xs leading-relaxed">
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.
</p>
<legend class="fieldset-legend px-1">{$messages.accessibility_why()}</legend>
<p class="text-xs leading-relaxed">{$messages.accessibility_explanation()}</p>
<ol class="mt-3 list-decimal space-y-1 pl-5 text-xs">
<li>Click <strong>Open Accessibility Settings</strong>.</li>
<li>Turn on Friendolls in Privacy &amp; Security → Accessibility.</li>
<li>Return here; this wizard confirms the change automatically.</li>
<li>{$messages.accessibility_step_open()}</li>
<li>{$messages.accessibility_step_enable()}</li>
<li>{$messages.accessibility_step_return()}</li>
</ol>
<button
class="btn mt-4 w-fit"
@@ -33,7 +30,9 @@
disabled={busy || permissionGranted}
onclick={onrequest}
>
{permissionGranted ? "Access Granted" : "Open Accessibility Settings…"}
{permissionGranted
? $messages.accessibility_access_granted()
: $messages.accessibility_open_settings()}
</button>
</fieldset>
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { messages } from "$lib/i18n";
</script>
<div class="space-y-4 text-sm">
<div class="grid size-14 place-content-center border-2 border-success bg-success/10 text-3xl text-success">✓</div>
<p>
Click <strong>Finish</strong> to save setup and launch Friendolls. The
control panel remains available from the menu bar or system tray.
</p>
<p>{$messages.onboarding_complete_body()}</p>
</div>
@@ -1,22 +1,24 @@
<script lang="ts">
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import { messages } from "$lib/i18n";
let { friendId = $bindable(), busy }: { friendId: string; busy: boolean } =
$props();
</script>
<PanelMessage
>If no one has shared a key with you yet, leave this empty and continue. You
can add friends anytime afterwards.</PanelMessage
>
<PanelMessage>{$messages.onboarding_friend_skip()}</PanelMessage>
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Friend identity</legend>
<label class="label" for="onboarding-friend-key">Identification Key</label>
<legend class="fieldset-legend px-1"
>{$messages.common_friend_identity()}</legend
>
<label class="label" for="onboarding-friend-key"
>{$messages.common_identification_key()}</label
>
<textarea
id="onboarding-friend-key"
class="textarea h-24 w-full resize-none font-mono text-[10px]"
class="textarea h-24 w-full resize-none text-[10px]"
bind:value={friendId}
autocomplete="off"
disabled={busy}></textarea>
<p class="label">Verify this key with your friend before adding it.</p>
<p class="label">{$messages.friend_key_verify()}</p>
</fieldset>
@@ -1,10 +1,11 @@
<script lang="ts">
import { messages } from "$lib/i18n";
let { displayName = $bindable(), busy }: { displayName: string; busy: boolean } = $props();
</script>
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Identity</legend>
<label class="label" for="onboarding-display-name">Display name</label>
<legend class="fieldset-legend px-1">{$messages.onboarding_identity_legend()}</legend>
<label class="label" for="onboarding-display-name">{$messages.common_display_name()}</label>
<input
id="onboarding-display-name"
class="input w-full"
@@ -13,5 +14,5 @@
autocomplete="off"
disabled={busy}
/>
<p class="label">Up to 64 characters. You can change it later.</p>
<p class="label">{$messages.onboarding_identity_help()}</p>
</fieldset>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PuppetMovementMode } from "$lib/bindings";
import { messages } from "$lib/i18n";
let {
movementMode = $bindable(),
@@ -8,9 +9,9 @@
</script>
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Puppets reposition</legend>
<legend class="fieldset-legend px-1">{$messages.puppet_movement()}</legend>
<div class="grid grid-cols-2 gap-3">
{#each [{ id: "free" as const, image: "/puppet-movement-free.svg", alt: "Puppets moving freely across the full viewport", label: "Freeroam around" }, { id: "bottom" as const, image: "/puppet-movement-bottom.svg", alt: "Puppets moving along the bottom of the viewport", label: "Stay on the ground" }] as option (option.id)}
{#each [{ id: "free" as const, image: "/puppet-movement-free.svg", alt: $messages.puppet_free_alt(), label: $messages.puppet_free() }, { id: "bottom" as const, image: "/puppet-movement-bottom.svg", alt: $messages.puppet_bottom_alt(), label: $messages.puppet_bottom() }] as option (option.id)}
<label
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
class:border-primary={movementMode === option.id}
@@ -1,5 +1,6 @@
<script lang="ts">
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import { messages } from "$lib/i18n";
let {
serverName = $bindable(),
@@ -14,14 +15,11 @@
} = $props();
</script>
<PanelMessage
>If you do not have a server yet, leave this empty and continue. You can add
one later.</PanelMessage
>
<PanelMessage>{$messages.onboarding_server_skip()}</PanelMessage>
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Server details</legend>
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
<label class="label" for="onboarding-server-name"
>Friendly name (optional)</label
>{$messages.common_optional_friendly_name()}</label
>
<input
id="onboarding-server-name"
@@ -31,7 +29,7 @@
autocomplete="off"
disabled={busy}
/>
<label class="label mt-1" for="onboarding-server-address">Address</label>
<label class="label mt-1" for="onboarding-server-address">{$messages.common_address()}</label>
<input
id="onboarding-server-address"
class="input w-full"
@@ -40,7 +38,7 @@
autocomplete="off"
disabled={busy}
/>
<label class="label mt-1" for="onboarding-server-port">Port (optional)</label>
<label class="label mt-1" for="onboarding-server-port">{$messages.common_optional_port()}</label>
<input
id="onboarding-server-port"
class="input w-28"
@@ -1,6 +1,7 @@
<script lang="ts">
import type { SceneConfiguration, User } from "$lib/bindings";
import PuppetPreview from "../../scene/components/renderer/puppet/preview.svelte";
import { messages } from "$lib/i18n";
let {
profile,
@@ -34,7 +35,7 @@
</div>
</div>
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
<legend class="fieldset-legend px-1">Puppet skin</legend>
<legend class="fieldset-legend px-1">{$messages.onboarding_skin_legend()}</legend>
{#if profile.skinHash}
<label class="label cursor-pointer justify-start gap-2">
<input
@@ -45,7 +46,7 @@
bind:group={skinMode}
disabled={busy}
/>
Keep current skin
{$messages.onboarding_skin_keep()}
</label>
{/if}
<label class="label cursor-pointer justify-start gap-2">
@@ -57,7 +58,7 @@
bind:group={skinMode}
disabled={busy}
/>
Use default skin
{$messages.onboarding_skin_default()}
</label>
<label class="label cursor-pointer justify-start gap-2">
<input
@@ -68,7 +69,7 @@
bind:group={skinMode}
disabled={busy}
/>
Choose a PNG file
{$messages.onboarding_skin_choose_png()}
</label>
<input
class="file-input file-input-sm mt-2 w-full"
@@ -77,6 +78,6 @@
disabled={busy}
onchange={(event) => onselect(event.currentTarget.files?.[0] ?? null)}
/>
<p class="label">Skins must be exactly 64×64 pixels.</p>
<p class="label">{$messages.onboarding_skin_help()}</p>
</fieldset>
</div>
@@ -1,16 +1,51 @@
<script lang="ts">
import {
errorMessage,
languageOptions,
localePreference,
messages,
setLanguagePreference,
} from "$lib/i18n";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
let {
requiresAccessibilityPermission,
}: { requiresAccessibilityPermission: boolean } = $props();
let busy = $state(false);
let error = $state("");
async function changeLanguage(event: Event) {
busy = true;
try {
error = "";
await setLanguagePreference(
(event.currentTarget as HTMLSelectElement).value,
);
} catch (cause) {
error = errorMessage(cause);
} finally {
busy = false;
}
}
</script>
<div class="space-y-4 text-sm">
<p>
Welcome to the Friendolls setup wizard, which will guide you through the
process of setting up Friendolls.
</p>
<p>
Friendolls turns your friends into desktop puppets that follow their cursors
and appear when they are online.
</p>
<p>{$messages.onboarding_welcome_intro()}</p>
<p>{$messages.onboarding_welcome_description()}</p>
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">{$messages.language_label()}</legend>
<select
class="select w-full"
aria-label={$messages.language_label()}
value={$localePreference}
disabled={busy}
onchange={changeLanguage}
>
{#each languageOptions as option (option.value)}
<option value={option.value}>{option.label($messages)}</option>
{/each}
</select>
</fieldset>
</div>
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import Popover from "$lib/components/popover.svelte";
import { friendName, friends } from "$lib/listeners/friends";
import { onlineFriendIds } from "$lib/listeners/friend-statuses";
@@ -14,6 +15,7 @@
import SceneImageViewer from "./popovers/image-viewer.svelte";
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
import { messages } from "$lib/i18n";
let selectedUserId = $state<string | null>(null);
let lockedPopoverUserId = $state<string | null>(null);
@@ -39,6 +41,10 @@
),
);
$effect(() => {
void getCurrentWindow().setTitle($messages.scene_window_title());
});
onMount(startHitboxSync);
$effect(() => {
@@ -66,7 +72,7 @@
? $profile.displayName
: friendName(
$friends.find((friend) => friend.id === userId),
"Unknown user",
$messages.common_unknown_user(),
);
}
@@ -100,6 +106,8 @@
}
</script>
<svelte:head><title>{$messages.scene_window_title()}</title></svelte:head>
<div class="relative size-full">
<Renderer
puppets={visiblePuppets}
@@ -147,7 +155,7 @@
<Popover
id={popoverId}
label="Show live user information"
label={$messages.scene_show_user()}
labelledBy={`${popoverId}-title`}
open={selectedUserId === userId}
onOpenChange={(open) => setPopoverOpen(userId, open)}
@@ -1,4 +1,5 @@
<script lang="ts">
import { messages } from "$lib/i18n";
import { onDestroy, onMount } from "svelte";
import * as THREE from "three";
import { PuppetVisual, SOUTH_WEST_ROTATION_Y } from "./visual";
@@ -91,7 +92,7 @@
<div
class="aspect-square size-full overflow-hidden"
aria-label="Live puppet preview"
aria-label={$messages.scene_loading_preview()}
role="img"
bind:this={container}
></div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, scale } from "svelte/transition";
import { messages } from "$lib/i18n";
type Props = {
source: string;
@@ -26,12 +27,12 @@
class="relative grid max-h-full max-w-full place-items-center border border-white/20 bg-black/70 p-2 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={`Image from ${senderName}`}
aria-label={$messages.image_from({ sender: senderName })}
transition:scale={{ start: 0.96, duration: 180 }}
>
<img
src={source}
alt={`Image sent by ${senderName}`}
alt={$messages.image_sent_by({ sender: senderName })}
class="max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)] object-contain"
/>
<div
@@ -42,7 +43,7 @@
bind:this={closeButton}
type="button"
class="grid size-6 place-items-center text-lg leading-none hover:bg-white/15 focus-visible:outline focus-visible:outline-white"
aria-label="Close image"
aria-label={$messages.image_close()}
onclick={onClose}
>
×
@@ -1,6 +1,7 @@
<script lang="ts">
import { fly } from "svelte/transition";
import type { FriendInteractionReceived } from "$lib/bindings";
import { messages } from "$lib/i18n";
type Props = {
interaction: FriendInteractionReceived;
@@ -30,7 +31,7 @@
<button
type="button"
class="grid size-5 place-items-center text-xs text-base-content/50 transition-colors hover:text-base-content"
aria-label="Dismiss interaction"
aria-label={$messages.interaction_dismiss()}
onclick={onDismiss}
>
×
@@ -43,7 +44,7 @@
class="origin-bottom-right animate-[wiggle_650ms_ease-in-out_2] text-xl"
>👋</span
>
<span>waved</span>
<span>{$messages.interaction_waved()}</span>
</div>
{:else if interaction.content.type === "text"}
<p
@@ -55,16 +56,16 @@
<button
type="button"
class="block w-full cursor-zoom-in bg-base-200"
aria-label={`Open image from ${senderName}`}
aria-label={$messages.interaction_open_image({ sender: senderName })}
onclick={() => onOpenImage(imageSource)}
>
<img
src={imageSource}
alt={`Preview sent by ${senderName}`}
alt={$messages.interaction_preview_image({ sender: senderName })}
class="h-24 w-full object-cover"
/>
<span class="block px-2 py-1 text-[0.6rem] text-base-content/60">
Click to enlarge
{$messages.interaction_enlarge()}
</span>
</button>
{/if}
@@ -6,6 +6,7 @@
} from "$lib/bindings";
import { friendName, friends } from "$lib/listeners/friends";
import { profile } from "$lib/listeners/profile";
import { errorMessage, messages } from "$lib/i18n";
type Props = {
titleId: string;
@@ -35,7 +36,7 @@
? $profile.displayName
: friendName(
$friends.find((friend) => friend.id === userId),
"Unknown user",
$messages.common_unknown_user(),
),
);
@@ -54,7 +55,7 @@
mode = null;
onSent();
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
sending = false;
}
@@ -95,7 +96,7 @@
onSent();
}
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
sending = false;
}
@@ -115,7 +116,7 @@
{#if mode !== null}
<button
class="absolute right-1 top-1 grid size-6 place-items-center text-lg leading-none btn btn-square btn-ghost"
aria-label="Close interaction popover"
aria-label={$messages.scene_close_interaction()}
onclick={onDismiss}
>
×
@@ -123,7 +124,7 @@
{/if}
<section>
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
<h3 class="text-xs text-base-content/50">{$messages.scene_currently_enjoying()}</h3>
<div class="mt-1 flex min-w-0 items-center gap-2">
{#if foregroundApp?.ico}
<img
@@ -136,7 +137,7 @@
<p class="truncate text-sm">
{foregroundApp?.local ??
foregroundApp?.unlocal ??
"Waiting for data"}
$messages.scene_waiting_data()}
</p>
{#if foregroundApp?.local && foregroundApp.unlocal}
<p class="truncate text-xs text-base-content/50">
@@ -158,7 +159,7 @@
aria-pressed={mode === "message"}
onclick={() => selectMode("message")}
>
Message
{$messages.scene_message()}
</button>
<button
type="button"
@@ -166,7 +167,7 @@
disabled={sending}
onclick={() => sendContent({ type: "wave" })}
>
Wave
{$messages.scene_wave()}
</button>
<button
type="button"
@@ -176,7 +177,7 @@
aria-pressed={mode === "image"}
onclick={() => selectMode("image")}
>
Image
{$messages.scene_image()}
</button>
</div>
@@ -193,8 +194,8 @@
maxlength="500"
rows="3"
class="textarea textarea-xs w-full resize-none bg-base-100"
placeholder={`Write to ${username}`}
aria-label={`Message ${username}`}></textarea>
placeholder={$messages.scene_write_to({ username })}
aria-label={$messages.scene_message_user({ username })}></textarea>
<div class="flex items-center justify-between gap-2">
<span class="text-[0.6rem] text-base-content/45">
{message.length}/500
@@ -204,7 +205,7 @@
class="btn btn-primary btn-xs"
disabled={sending || !message.trim()}
>
{sending ? "Sending…" : "Send"}
{sending ? $messages.scene_sending() : $messages.scene_send()}
</button>
</div>
</form>
@@ -213,9 +214,9 @@
class="mt-2 grid min-h-24 place-items-center border border-primary/60 bg-primary/5 p-2 text-center"
>
<div>
<p class="text-xs">Choose or paste an image</p>
<p class="text-xs">{$messages.scene_choose_or_paste()}</p>
<p class="mt-0.5 text-[0.6rem] text-base-content/45">
Compressed to 480 px · 150 KiB
{$messages.scene_compression_note()}
</p>
<button
type="button"
@@ -223,7 +224,9 @@
disabled={sending}
onclick={sendImage}
>
{sending ? "Compressing…" : "Choose image"}
{sending
? $messages.scene_compressing()
: $messages.scene_choose_image()}
</button>
</div>
</div>
Binary file not shown.
+11 -1
View File
@@ -1,13 +1,23 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { paraglideVitePlugin } from "@inlang/paraglide-js";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit(), tailwindcss()],
plugins: [
sveltekit(),
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/paraglide",
strategy: ["globalVariable", "preferredLanguage", "baseLocale"],
emitTsDeclarations: true,
}),
tailwindcss(),
],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//