Add Internationalization Support (#437)

* basic i18n

* tweak

* translate app

* add linting to make sure we have translations

* run linter on pr

* format
This commit is contained in:
CJ Pais 2025-12-11 20:16:54 -08:00 committed by GitHub
parent 700eb71abb
commit f87051f464
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2945 additions and 296 deletions

18
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: "lint"
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run ESLint
run: bun run lint

View file

@ -0,0 +1,174 @@
# Contributing Translations to Handy
Thank you for helping translate Handy! This guide explains how to add or improve translations.
## Quick Start
1. Fork the repository
2. Copy the English translation file to your language folder
3. Translate the values (not the keys!)
4. Submit a pull request
## File Structure
Translation files are located in:
```
src/i18n/locales/
├── en/
│ └── translation.json # English (source)
├── vi/
│ └── translation.json # Vietnamese
├── fr/
│ └── translation.json # French
└── [your-language]/
└── translation.json # Your contribution!
```
## Adding a New Language
### Step 1: Create the Language Folder
Create a new folder using the [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes):
```bash
mkdir src/i18n/locales/[language-code]
```
Examples:
- `de` for German
- `es` for Spanish
- `ja` for Japanese
- `zh` for Chinese
- `ko` for Korean
- `pt` for Portuguese
### Step 2: Copy the English File
```bash
cp src/i18n/locales/en/translation.json src/i18n/locales/[language-code]/translation.json
```
### Step 3: Translate the Values
Open the file and translate only the **values** (right side), not the keys (left side):
```json
{
"sidebar": {
"general": "General", // ← Translate this value
"advanced": "Advanced", // ← Translate this value
...
}
}
```
**Important:**
- Keep all keys exactly the same
- Preserve any `{{variables}}` in the text (e.g., `{{error}}`, `{{model}}`)
- Keep the JSON structure and formatting intact
### Step 4: Register Your Language
Edit `src/i18n/languages.ts` and add your language metadata:
```typescript
export const LANGUAGE_METADATA: Record<
string,
{ name: string; nativeName: string }
> = {
en: { name: "English", nativeName: "English" },
es: { name: "Spanish", nativeName: "Español" },
fr: { name: "French", nativeName: "Français" },
vi: { name: "Vietnamese", nativeName: "Tiếng Việt" },
de: { name: "German", nativeName: "Deutsch" }, // ← Add your language
};
```
### Step 5: Test Your Translation
1. Run the app: `bun run tauri dev`
2. Go to Settings → General → App Language
3. Select your language
4. Verify all text displays correctly
### Step 6: Submit a Pull Request
1. Commit your changes
2. Push to your fork
3. Open a pull request with:
- Language name in the title (e.g., "Add German translation")
- Any notes about the translation
## Improving Existing Translations
Found a typo or better translation?
1. Edit the relevant `translation.json` file
2. Submit a PR with a brief description of the change
## Translation Guidelines
### Do:
- Use natural, native-sounding language
- Keep translations concise (UI space is limited)
- Match the tone of the English text (friendly, clear)
- Preserve technical terms when appropriate (e.g., "API", "GPU")
### Don't:
- Translate brand names (Handy, Whisper.cpp, OpenAI)
- Change or remove `{{variables}}`
- Modify JSON keys
- Add extra spaces or formatting
### Handling Variables
Some strings contain variables like `{{error}}` or `{{model}}`. Keep these exactly as-is:
```json
// English
"downloadModel": "Failed to download model: {{error}}"
// French (correct)
"downloadModel": "Échec du téléchargement du modèle : {{error}}"
// French (incorrect - don't translate the variable!)
"downloadModel": "Échec du téléchargement du modèle : {{erreur}}"
```
### Handling Plurals
Some languages have complex plural rules. For now, use a general form that works for all cases. We may add proper plural support in the future.
## Questions?
- Open an issue on GitHub
- Join the discussion in existing translation PRs
## Currently Supported Languages
| Language | Code | Status |
| ---------- | ---- | ----------------- |
| English | `en` | Complete (source) |
| Spanish | `es` | Complete |
| French | `fr` | Complete |
| Vietnamese | `vi` | Complete |
## Requested Languages
We'd love help with:
- German (`de`)
- Japanese (`ja`)
- Chinese (`zh`)
- Korean (`ko`)
- Portuguese (`pt`)
- And more!
---
Thank you for making Handy accessible to more people around the world!

213
bun.lock
View file

@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "handy-app",
@ -17,9 +16,11 @@
"@tauri-apps/plugin-sql": "~2.3.1",
"@tauri-apps/plugin-store": "~2.4.1",
"@tauri-apps/plugin-updater": "~2.9.0",
"i18next": "^25.7.2",
"lucide-react": "^0.542.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.4.1",
"react-select": "^5.8.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.16",
@ -33,7 +34,11 @@
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@types/react-select": "^5.0.1",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.39.1",
"eslint-plugin-i18next": "^6.1.3",
"prettier": "^3.6.2",
"typescript": "~5.6.3",
"vite": "^6.4.1",
@ -155,12 +160,38 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
"@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@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=="],
@ -303,6 +334,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
@ -317,26 +350,72 @@
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.49.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.49.0", "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.49.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.49.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.49.0", "@typescript-eslint/tsconfig-utils": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
@ -353,38 +432,102 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
"eslint-plugin-i18next": ["eslint-plugin-i18next@6.1.3", "", { "dependencies": { "lodash": "^4.17.21", "requireindex": "~1.1.0" } }, "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@25.7.2", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@ -411,6 +554,12 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@ -421,18 +570,32 @@
"memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
@ -443,14 +606,20 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-i18next": ["react-i18next@16.4.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@ -459,6 +628,8 @@
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"requireindex": ["requireindex@1.1.0", "", {}, "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -467,7 +638,11 @@
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
@ -475,8 +650,12 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
@ -487,26 +666,50 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@6.4.1", "", { "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-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="],
@ -518,5 +721,11 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
}
}

38
eslint.config.js Normal file
View file

@ -0,0 +1,38 @@
import i18next from "eslint-plugin-i18next";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
i18next,
},
rules: {
// Catch text in JSX that should be translated
"i18next/no-literal-string": [
"error",
{
markupOnly: true, // Only check JSX content, not all strings
ignoreAttribute: [
"className",
"style",
"type",
"id",
"name",
"key",
"data-*",
"aria-*",
], // Ignore common non-translatable attributes
},
],
},
},
];

View file

@ -8,6 +8,8 @@
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write . && cd src-tauri && cargo fmt",
"format:check": "prettier --check . && cd src-tauri && cargo fmt -- --check",
"format:frontend": "prettier --write .",
@ -28,9 +30,11 @@
"@tauri-apps/plugin-updater": "~2.9.0",
"react-select": "^5.8.0",
"tauri-plugin-macos-permissions-api": "2.3.0",
"i18next": "^25.7.2",
"lucide-react": "^0.542.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.4.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.16",
"zod": "^3.25.76",
@ -42,7 +46,11 @@
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@types/react-select": "^5.0.1",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.39.1",
"eslint-plugin-i18next": "^6.1.3",
"prettier": "^3.6.2",
"typescript": "~5.6.3",
"vite": "^6.4.1"

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
checkAccessibilityPermission,
requestAccessibilityPermission,
@ -14,6 +15,7 @@ interface ButtonConfig {
}
const AccessibilityPermissions: React.FC = () => {
const { t } = useTranslation();
const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);
const [permissionState, setPermissionState] =
useState<PermissionState>("request");
@ -61,12 +63,12 @@ const AccessibilityPermissions: React.FC = () => {
// Configure button text and style based on state
const buttonConfig: Record<PermissionState, ButtonConfig | null> = {
request: {
text: "Grant",
text: t("accessibility.openSettings"),
className:
"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary",
},
verify: {
text: "Verify",
text: t("accessibility.openSettings"),
className:
"bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded text-sm flex items-center justify-center cursor-pointer",
},
@ -80,7 +82,7 @@ const AccessibilityPermissions: React.FC = () => {
<div className="flex justify-between items-center gap-2">
<div className="">
<p className="text-sm font-medium">
Please grant accessibility permissions for Handy
{t("accessibility.permissionsDescription")}
</p>
</div>
<button

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react";
import HandyTextLogo from "./icons/HandyTextLogo";
import HandyHand from "./icons/HandyHand";
@ -23,7 +24,7 @@ interface IconProps {
}
interface SectionConfig {
label: string;
labelKey: string;
icon: React.ComponentType<IconProps>;
component: React.ComponentType;
enabled: (settings: any) => boolean;
@ -31,37 +32,37 @@ interface SectionConfig {
export const SECTIONS_CONFIG = {
general: {
label: "General",
labelKey: "sidebar.general",
icon: HandyHand,
component: GeneralSettings,
enabled: () => true,
},
advanced: {
label: "Advanced",
labelKey: "sidebar.advanced",
icon: Cog,
component: AdvancedSettings,
enabled: () => true,
},
postprocessing: {
label: "Post Process",
labelKey: "sidebar.postProcessing",
icon: Sparkles,
component: PostProcessingSettings,
enabled: (settings) => settings?.post_process_enabled ?? false,
},
history: {
label: "History",
labelKey: "sidebar.history",
icon: History,
component: HistorySettings,
enabled: () => true,
},
debug: {
label: "Debug",
labelKey: "sidebar.debug",
icon: FlaskConical,
component: DebugSettings,
enabled: (settings) => settings?.debug_mode ?? false,
},
about: {
label: "About",
labelKey: "sidebar.about",
icon: Info,
component: AboutSettings,
enabled: () => true,
@ -77,6 +78,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
activeSection,
onSectionChange,
}) => {
const { t } = useTranslation();
const { settings } = useSettings();
const availableSections = Object.entries(SECTIONS_CONFIG)
@ -102,7 +104,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClick={() => onSectionChange(section.id)}
>
<Icon width={24} height={24} />
<p className="text-sm font-medium">{section.label}</p>
<p className="text-sm font-medium">{t(section.labelKey)}</p>
</div>
);
})}

View file

@ -32,6 +32,7 @@ const Footer: React.FC = () => {
<div className="flex items-center gap-1">
<UpdateChecker />
<span></span>
{/* eslint-disable-next-line i18next/no-literal-string */}
<span>v{version}</span>
</div>
</div>

View file

@ -1,6 +1,11 @@
import React from "react";
import { useTranslation } from "react-i18next";
import type { ModelInfo } from "@/bindings";
import { formatModelSize } from "../../lib/utils/format";
import {
getTranslatedModelName,
getTranslatedModelDescription,
} from "../../lib/utils/modelTranslation";
import { ProgressBar } from "../shared";
interface DownloadProgress {
@ -29,6 +34,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
onModelDelete,
onError,
}) => {
const { t } = useTranslation();
const availableModels = models.filter((m) => m.is_downloaded);
const downloadableModels = models.filter((m) => !m.is_downloaded);
const isFirstRun = availableModels.length === 0 && models.length > 0;
@ -65,10 +71,10 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
{isFirstRun && (
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
<div className="text-xs font-medium text-logo-primary mb-1">
Welcome to Handy!
{t("modelSelector.welcome")}
</div>
<div className="text-xs text-text/70">
Download a model below to get started with transcription.
{t("modelSelector.downloadPrompt")}
</div>
</div>
)}
@ -77,7 +83,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
{availableModels.length > 0 && (
<div>
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
Available Models
{t("modelSelector.availableModels")}
</div>
{availableModels.map((model) => (
<div
@ -99,20 +105,26 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm">{model.name}</div>
<div className="text-sm">
{getTranslatedModelName(model, t)}
</div>
<div className="text-xs text-text/40 italic pr-4">
{model.description}
{getTranslatedModelDescription(model, t)}
</div>
</div>
<div className="flex items-center gap-2">
{currentModelId === model.id && (
<div className="text-xs text-logo-primary">Active</div>
<div className="text-xs text-logo-primary">
{t("modelSelector.active")}
</div>
)}
{currentModelId !== model.id && (
<button
onClick={(e) => handleDeleteClick(e, model.id)}
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
title={`Delete ${model.name}`}
title={t("modelSelector.deleteModel", {
modelName: getTranslatedModelName(model, t),
})}
>
<svg
className="w-3 h-3"
@ -141,7 +153,9 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
<div className="border-t border-mid-gray/10 my-1" />
)}
<div className="px-3 py-1 text-xs font-medium text-text/80">
{isFirstRun ? "Choose a Model" : "Download Models"}
{isFirstRun
? t("modelSelector.chooseModel")
: t("modelSelector.downloadModels")}
</div>
{downloadableModels.map((model) => {
const isDownloading = downloadProgress.has(model.id);
@ -169,24 +183,25 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
<div className="flex items-center justify-between">
<div>
<div className="text-sm">
{model.name}
{model.id === "small" && isFirstRun && (
{getTranslatedModelName(model, t)}
{model.id === "parakeet-tdt-0.6b-v3" && isFirstRun && (
<span className="ml-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
Recommended
{t("onboarding.recommended")}
</span>
)}
</div>
<div className="text-xs text-text/40 italic pr-4">
{model.description}
{getTranslatedModelDescription(model, t)}
</div>
<div className="mt-1 text-xs text-text/50 tabular-nums">
Download size · {formatModelSize(Number(model.size_mb))}
{t("modelSelector.downloadSize")} ·{" "}
{formatModelSize(Number(model.size_mb))}
</div>
</div>
<div className="text-xs text-logo-primary tabular-nums">
{isDownloading && progress
? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%`
: "Download"}
: t("modelSelector.download")}
</div>
</div>
@ -213,7 +228,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
{/* No Models Available */}
{availableModels.length === 0 && downloadableModels.length === 0 && (
<div className="px-3 py-2 text-sm text-text/60">
No models available
{t("modelSelector.noModelsAvailable")}
</div>
)}
</div>

View file

@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { commands, type ModelInfo } from "@/bindings";
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
import ModelStatusButton from "./ModelStatusButton";
import ModelDropdown from "./ModelDropdown";
import DownloadProgressDisplay from "./DownloadProgressDisplay";
@ -40,6 +42,7 @@ interface ModelSelectorProps {
}
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
const { t } = useTranslation();
const [models, setModels] = useState<ModelInfo[]>([]);
const [currentModelId, setCurrentModelId] = useState<string>("");
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
@ -322,9 +325,14 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
if (extractingModels.size === 1) {
const [modelId] = Array.from(extractingModels);
const model = models.find((m) => m.id === modelId);
return `Extracting ${model?.name || "Model"}...`;
const modelName = model
? getTranslatedModelName(model, t)
: t("modelSelector.extractingGeneric").replace("...", "");
return t("modelSelector.extracting", { modelName });
} else {
return `Extracting ${extractingModels.size} models...`;
return t("modelSelector.extractingMultiple", {
count: extractingModels.size,
});
}
}
@ -335,9 +343,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
0,
Math.min(100, Math.round(progress.percentage)),
);
return `Downloading ${percentage}%`;
return t("modelSelector.downloading", { percentage });
} else {
return `Downloading ${modelDownloadProgress.size} models...`;
return t("modelSelector.downloadingMultiple", {
count: modelDownloadProgress.size,
});
}
}
@ -345,21 +355,33 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
switch (modelStatus) {
case "ready":
return currentModel?.name || "Model Ready";
return currentModel
? getTranslatedModelName(currentModel, t)
: t("modelSelector.modelReady");
case "loading":
return currentModel ? `Loading ${currentModel.name}...` : "Loading...";
return currentModel
? t("modelSelector.loading", {
modelName: getTranslatedModelName(currentModel, t),
})
: t("modelSelector.loadingGeneric");
case "extracting":
return currentModel
? `Extracting ${currentModel.name}...`
: "Extracting...";
? t("modelSelector.extracting", {
modelName: getTranslatedModelName(currentModel, t),
})
: t("modelSelector.extractingGeneric");
case "error":
return modelError || "Model Error";
return modelError || t("modelSelector.modelError");
case "unloaded":
return currentModel?.name || "Model Unloaded";
return currentModel
? getTranslatedModelName(currentModel, t)
: t("modelSelector.modelUnloaded");
case "none":
return "No Model - Download Required";
return t("modelSelector.noModelDownloadRequired");
default:
return currentModel?.name || "Model Unloaded";
return currentModel
? getTranslatedModelName(currentModel, t)
: t("modelSelector.modelUnloaded");
}
};

View file

@ -1,7 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Download } from "lucide-react";
import type { ModelInfo } from "@/bindings";
import { formatModelSize } from "../../lib/utils/format";
import {
getTranslatedModelName,
getTranslatedModelDescription,
} from "../../lib/utils/modelTranslation";
import Badge from "../ui/Badge";
interface ModelCardProps {
@ -19,8 +24,13 @@ const ModelCard: React.FC<ModelCardProps> = ({
className = "",
onSelect,
}) => {
const { t } = useTranslation();
const isFeatured = variant === "featured";
// Get translated model name and description
const displayName = getTranslatedModelName(model, t);
const displayDescription = getTranslatedModelDescription(model, t);
const baseButtonClasses =
"flex justify-between items-center rounded-xl p-3 px-4 text-left transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] cursor-pointer group";
@ -40,19 +50,23 @@ const ModelCard: React.FC<ModelCardProps> = ({
<div className="flex flex-col items-ce">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
{model.name}
{displayName}
</h3>
<DownloadSize sizeMb={Number(model.size_mb)} />
{isFeatured && <Badge variant="primary">Recommended</Badge>}
{isFeatured && (
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
)}
</div>
<p className="text-text/60 text-sm leading-relaxed">
{model.description}
{displayDescription}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-xs text-text/70 w-16 text-right">accuracy</p>
<p className="text-xs text-text/70 w-16 text-right">
{t("onboarding.modelCard.accuracy")}
</p>
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full transition-all duration-300"
@ -61,7 +75,9 @@ const ModelCard: React.FC<ModelCardProps> = ({
</div>
</div>
<div className="flex items-center gap-2">
<p className="text-xs text-text/70 w-16 text-right">speed</p>
<p className="text-xs text-text/70 w-16 text-right">
{t("onboarding.modelCard.speed")}
</p>
<div className="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
<div
className="h-full bg-logo-primary rounded-full transition-all duration-300"
@ -75,6 +91,8 @@ const ModelCard: React.FC<ModelCardProps> = ({
};
const DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5 text-xs text-text/60 tabular-nums">
<Download
@ -82,7 +100,7 @@ const DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
className="h-3.5 w-3.5 text-text/45"
strokeWidth={1.75}
/>
<span className="sr-only">Download size</span>
<span className="sr-only">{t("modelSelector.downloadSize")}</span>
<span className="font-medium text-text/70">
{formatModelSize(sizeMb)}
</span>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { commands, type ModelInfo } from "@/bindings";
import ModelCard from "./ModelCard";
import HandyTextLogo from "../icons/HandyTextLogo";
@ -8,6 +9,7 @@ interface OnboardingProps {
}
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
const { t } = useTranslation();
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -23,11 +25,11 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
// Only show downloadable models for onboarding
setAvailableModels(result.data.filter((m) => !m.is_downloaded));
} else {
setError("Failed to load available models");
setError(t("onboarding.errors.loadModels"));
}
} catch (err) {
console.error("Failed to load models:", err);
setError("Failed to load available models");
setError(t("onboarding.errors.loadModels"));
}
};
@ -42,12 +44,12 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
const result = await commands.downloadModel(modelId);
if (result.status === "error") {
console.error("Download failed:", result.error);
setError(`Failed to download model: ${result.error}`);
setError(t("onboarding.errors.downloadModel", { error: result.error }));
setDownloading(false);
}
} catch (err) {
console.error("Download failed:", err);
setError(`Failed to download model: ${err}`);
setError(t("onboarding.errors.downloadModel", { error: String(err) }));
setDownloading(false);
}
};
@ -61,7 +63,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
<div className="flex flex-col items-center gap-2 shrink-0">
<HandyTextLogo width={200} />
<p className="text-text/70 max-w-md font-medium mx-auto">
To get started, choose a transcription model
{t("onboarding.subtitle")}
</p>
</div>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface AlwaysOnMicrophoneProps {
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const alwaysOnMode = getSetting("always_on_microphone") || false;
@ -18,8 +20,8 @@ export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(
checked={alwaysOnMode}
onChange={(enabled) => updateSetting("always_on_microphone", enabled)}
isUpdating={isUpdating("always_on_microphone")}
label="Always-On Microphone"
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
label={t("settings.debug.alwaysOnMicrophone.label")}
description={t("settings.debug.alwaysOnMicrophone.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { commands } from "@/bindings";
import { SettingContainer } from "../ui/SettingContainer";
import { Button } from "../ui/Button";
@ -12,6 +13,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { t } = useTranslation();
const [appDirPath, setAppDirPath] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -59,7 +61,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">
Error loading app directory: {error}
{t("errors.loadDirectory", { error })}
</p>
</div>
);
@ -67,8 +69,8 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
return (
<SettingContainer
title="App Data Directory"
description="Main directory where application data, settings, and models are stored"
title={t("settings.about.appDataDirectory.title")}
description={t("settings.about.appDataDirectory.description")}
descriptionMode={descriptionMode}
grouped={grouped}
layout="stacked"
@ -84,7 +86,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
disabled={!appDirPath}
className="px-3 py-2"
>
Open
{t("common.open")}
</Button>
</div>
</SettingContainer>

View file

@ -0,0 +1,45 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n";
interface AppLanguageSelectorProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
export const AppLanguageSelector: React.FC<AppLanguageSelectorProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t, i18n } = useTranslation();
const currentLanguage = i18n.language as SupportedLanguageCode;
const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({
value: lang.code,
label: `${lang.nativeName} (${lang.name})`,
}));
const handleLanguageChange = (langCode: string) => {
i18n.changeLanguage(langCode);
// Persist to localStorage for next session
localStorage.setItem("handy-app-language", langCode);
};
return (
<SettingContainer
title={t("appLanguage.title")}
description={t("appLanguage.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<Dropdown
options={languageOptions}
selectedValue={currentLanguage}
onSelect={handleLanguageChange}
/>
</SettingContainer>
);
});
AppLanguageSelector.displayName = "AppLanguageSelector";

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface AppendTrailingSpaceProps {
export const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const enabled = getSetting("append_trailing_space") ?? false;
@ -18,8 +20,8 @@ export const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =
checked={enabled}
onChange={(enabled) => updateSetting("append_trailing_space", enabled)}
isUpdating={isUpdating("append_trailing_space")}
label="Append Trailing Space"
description="Automatically add a space at the end of transcribed text, making it easier to dictate multiple sentences in a row."
label={t("settings.debug.appendTrailingSpace.label")}
description={t("settings.debug.appendTrailingSpace.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
import { VolumeSlider } from "./VolumeSlider";
@ -11,6 +12,7 @@ interface AudioFeedbackProps {
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
@ -20,8 +22,8 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
label={t("settings.sound.audioFeedback.label")}
description={t("settings.sound.audioFeedback.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface AutostartToggleProps {
export const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const autostartEnabled = getSetting("autostart_enabled") ?? false;
@ -18,8 +20,8 @@ export const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(
checked={autostartEnabled}
onChange={(enabled) => updateSetting("autostart_enabled", enabled)}
isUpdating={isUpdating("autostart_enabled")}
label="Launch on Startup"
description="Automatically start Handy when you log in to your computer."
label={t("settings.advanced.autostart.label")}
description={t("settings.advanced.autostart.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { commands } from "@/bindings";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
@ -12,6 +13,7 @@ interface ClamshellMicrophoneSelectorProps {
export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const {
getSetting,
updateSetting,
@ -67,8 +69,8 @@ export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorPr
return (
<SettingContainer
title="Clamshell Microphone"
description="Choose a different microphone to use when your laptop lid is closed"
title={t("settings.debug.clamshellMicrophone.title")}
description={t("settings.debug.clamshellMicrophone.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
@ -79,8 +81,8 @@ export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorPr
onSelect={handleClamshellMicrophoneSelect}
placeholder={
isLoading || audioDevices.length === 0
? "Loading..."
: "Select microphone..."
? t("common.loading")
: t("settings.sound.microphone.placeholder")
}
disabled={
isUpdating("clamshell_microphone") ||

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
@ -9,22 +10,29 @@ interface ClipboardHandlingProps {
grouped?: boolean;
}
const clipboardHandlingOptions = [
{ value: "dont_modify", label: "Don't Modify Clipboard" },
{ value: "copy_to_clipboard", label: "Copy to Clipboard" },
];
export const ClipboardHandlingSetting: React.FC<ClipboardHandlingProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const clipboardHandlingOptions = [
{
value: "dont_modify",
label: t("settings.advanced.clipboardHandling.options.dontModify"),
},
{
value: "copy_to_clipboard",
label: t("settings.advanced.clipboardHandling.options.copyToClipboard"),
},
];
const selectedHandling = (getSetting("clipboard_handling") ||
"dont_modify") as ClipboardHandling;
return (
<SettingContainer
title="Clipboard Handling"
description="Don't Modify Clipboard preserves your current clipboard contents after transcription. Copy to Clipboard leaves the transcription result in your clipboard after pasting."
title={t("settings.advanced.clipboardHandling.title")}
description={t("settings.advanced.clipboardHandling.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../../hooks/useSettings";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
@ -11,6 +12,7 @@ interface CustomWordsProps {
export const CustomWords: React.FC<CustomWordsProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const [newWord, setNewWord] = useState("");
const customWords = getSetting("custom_words") || [];
@ -46,8 +48,8 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
return (
<>
<SettingContainer
title="Custom Words"
description="Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list."
title={t("settings.advanced.customWords.title")}
description={t("settings.advanced.customWords.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
@ -58,7 +60,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Add a word"
placeholder={t("settings.advanced.customWords.placeholder")}
variant="compact"
disabled={isUpdating("custom_words")}
/>
@ -73,7 +75,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
variant="primary"
size="md"
>
Add
{t("settings.advanced.customWords.add")}
</Button>
</div>
</SettingContainer>
@ -89,7 +91,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
variant="secondary"
size="sm"
className="inline-flex items-center gap-1 cursor-pointer"
aria-label={`Remove ${word}`}
aria-label={t("settings.advanced.customWords.remove", { word })}
>
<span>{word}</span>
<svg

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { type } from "@tauri-apps/plugin-os";
import {
getKeyName,
@ -25,6 +26,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
shortcutId,
disabled = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
useSettings();
const [keyPressed, setKeyPressed] = useState<string[]>([]);
@ -89,7 +91,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
.catch(console.error);
} catch (error) {
console.error("Failed to restore original binding:", error);
toast.error("Failed to restore original shortcut");
toast.error(t("settings.general.shortcut.errors.restore"));
}
} else if (editingShortcutId) {
await commands.resumeBinding(editingShortcutId).catch(console.error);
@ -141,7 +143,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
.catch(console.error);
} catch (error) {
console.error("Failed to change binding:", error);
toast.error(`Failed to set shortcut: ${error}`);
toast.error(
t("settings.general.shortcut.errors.set", {
error: String(error),
}),
);
// Reset to original binding on error
if (originalBinding) {
@ -152,7 +158,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
.catch(console.error);
} catch (resetError) {
console.error("Failed to reset binding:", resetError);
toast.error("Failed to reset shortcut to original value");
toast.error(t("settings.general.shortcut.errors.reset"));
}
}
}
@ -180,7 +186,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
.catch(console.error);
} catch (error) {
console.error("Failed to restore original binding:", error);
toast.error("Failed to restore original shortcut");
toast.error(t("settings.general.shortcut.errors.restore"));
}
} else if (editingShortcutId) {
commands.resumeBinding(editingShortcutId).catch(console.error);
@ -228,7 +234,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
// Format the current shortcut keys being recorded
const formatCurrentKeys = (): string => {
if (recordedKeys.length === 0) return "Press keys...";
if (recordedKeys.length === 0)
return t("settings.general.shortcut.pressKeys");
// Use the same formatting as the display to ensure consistency
return formatKeyCombination(recordedKeys.join("+"), osType);
@ -243,12 +250,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (isLoading) {
return (
<SettingContainer
title="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">Loading shortcuts...</div>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.loading")}
</div>
</SettingContainer>
);
}
@ -257,12 +266,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (Object.keys(bindings).length === 0) {
return (
<SettingContainer
title="Handy Shortcuts"
description="Configure keyboard shortcuts to trigger speech-to-text recording"
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">No shortcuts configured</div>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
@ -271,20 +282,32 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
if (!binding) {
return (
<SettingContainer
title="Shortcut"
description="Shortcut not found"
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.notFound")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">No shortcut configured</div>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
// Get translated name and description for the binding
const translatedName = t(
`settings.general.shortcut.bindings.${shortcutId}.name`,
binding.name,
);
const translatedDescription = t(
`settings.general.shortcut.bindings.${shortcutId}.description`,
binding.description,
);
return (
<SettingContainer
title={binding.name}
description={binding.description}
title={translatedName}
description={translatedDescription}
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../../hooks/useSettings";
import { Input } from "../ui/Input";
import { SettingContainer } from "../ui/SettingContainer";
@ -12,6 +13,7 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const historyLimit = Number(getSetting("history_limit") ?? "5");
@ -25,8 +27,8 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
return (
<SettingContainer
title="History Limit"
description="Maximum number of transcription entries to keep in history"
title={t("settings.debug.historyLimit.title")}
description={t("settings.debug.historyLimit.description")}
descriptionMode={descriptionMode}
grouped={grouped}
layout="horizontal"
@ -41,7 +43,9 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
disabled={isUpdating("history_limit")}
className="w-20"
/>
<span className="text-sm text-text">entries</span>
<span className="text-sm text-text">
{t("settings.debug.historyLimit.entries")}
</span>
</div>
</SettingContainer>
);

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton";
@ -17,6 +18,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();
const { currentModel, loadCurrentModel } = useModels();
const [isOpen, setIsOpen] = useState(false);
@ -70,9 +72,9 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
);
const selectedLanguageName = isUnsupported
? "Auto"
? t("settings.general.language.auto")
: LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label ||
"Auto";
t("settings.general.language.auto");
const handleLanguageSelect = async (languageCode: string) => {
await updateSetting("selected_language", languageCode);
@ -105,11 +107,11 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
return (
<SettingContainer
title="Language"
title={t("settings.general.language.title")}
description={
isUnsupported
? "Parakeet model automatically detects the language. No manual selection is needed."
: "Select the language for speech recognition. Auto will automatically determine the language, while selecting a specific language can improve accuracy for that language."
? t("settings.general.language.descriptionUnsupported")
: t("settings.general.language.description")
}
descriptionMode={descriptionMode}
grouped={grouped}
@ -155,7 +157,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder="Search languages..."
placeholder={t("settings.general.language.searchPlaceholder")}
className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded focus:outline-none focus:ring-1 focus:ring-logo-primary focus:border-logo-primary"
/>
</div>
@ -163,7 +165,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
<div className="max-h-48 overflow-y-auto">
{filteredLanguages.length === 0 ? (
<div className="px-2 py-2 text-sm text-mid-gray text-center">
No languages found
{t("settings.general.language.noResults")}
</div>
) : (
filteredLanguages.map((language) => (

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton";
@ -11,6 +12,7 @@ interface MicrophoneSelectorProps {
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const {
getSetting,
updateSetting,
@ -41,8 +43,8 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
return (
<SettingContainer
title="Microphone"
description="Select your preferred microphone device"
title={t("settings.sound.microphone.title")}
description={t("settings.sound.microphone.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
@ -53,8 +55,8 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
onSelect={handleMicrophoneSelect}
placeholder={
isLoading || audioDevices.length === 0
? "Loading..."
: "Select microphone..."
? t("settings.sound.microphone.loading")
: t("settings.sound.microphone.placeholder")
}
disabled={
isUpdating("selected_microphone") ||

View file

@ -1,4 +1,5 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../../hooks/useSettings";
import { commands, type ModelUnloadTimeout } from "@/bindings";
import { Dropdown } from "../ui/Dropdown";
@ -9,27 +10,52 @@ interface ModelUnloadTimeoutProps {
grouped?: boolean;
}
const timeoutOptions = [
{ value: "never" as ModelUnloadTimeout, label: "Never" },
{ value: "immediately" as ModelUnloadTimeout, label: "Immediately" },
{ value: "min2" as ModelUnloadTimeout, label: "After 2 minutes" },
{ value: "min5" as ModelUnloadTimeout, label: "After 5 minutes" },
{ value: "min10" as ModelUnloadTimeout, label: "After 10 minutes" },
{ value: "min15" as ModelUnloadTimeout, label: "After 15 minutes" },
{ value: "hour1" as ModelUnloadTimeout, label: "After 1 hour" },
];
const debugTimeoutOptions = [
...timeoutOptions,
{ value: "sec5" as ModelUnloadTimeout, label: "After 5 seconds (Debug)" },
];
export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { t } = useTranslation();
const { settings, getSetting, updateSetting } = useSettings();
const timeoutOptions = [
{
value: "never" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.never"),
},
{
value: "immediately" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.immediately"),
},
{
value: "min2" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.min2"),
},
{
value: "min5" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.min5"),
},
{
value: "min10" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.min10"),
},
{
value: "min15" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.min15"),
},
{
value: "hour1" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.hour1"),
},
];
const debugTimeoutOptions = [
...timeoutOptions,
{
value: "sec5" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.sec5"),
},
];
const handleChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const newTimeout = event.target.value as ModelUnloadTimeout;
@ -49,8 +75,8 @@ export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
return (
<SettingContainer
title="Unload Model"
description="Automatically free GPU/CPU memory when the model hasn't been used for the specified time"
title={t("settings.advanced.modelUnload.title")}
description={t("settings.advanced.modelUnload.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface MuteWhileRecordingToggleProps {
export const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const muteEnabled = getSetting("mute_while_recording") ?? false;
@ -18,8 +20,8 @@ export const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =
checked={muteEnabled}
onChange={(enabled) => updateSetting("mute_while_recording", enabled)}
isUpdating={isUpdating("mute_while_recording")}
label="Mute While Recording"
description="Automatically mute all sound output while Handy is recording, then restore it when finished."
label={t("settings.debug.muteWhileRecording.label")}
description={t("settings.debug.muteWhileRecording.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton";
@ -14,6 +15,7 @@ interface OutputDeviceSelectorProps {
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
React.memo(
({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
const { t } = useTranslation();
const {
getSetting,
updateSetting,
@ -44,8 +46,8 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
return (
<SettingContainer
title="Output Device"
description="Select your preferred audio output device for feedback sounds"
title={t("settings.sound.outputDevice.title")}
description={t("settings.sound.outputDevice.description")}
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}
@ -57,8 +59,8 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
onSelect={handleOutputDeviceSelect}
placeholder={
isLoading || outputDevices.length === 0
? "Loading..."
: "Select output device..."
? t("settings.sound.outputDevice.loading")
: t("settings.sound.outputDevice.placeholder")
}
disabled={
disabled ||

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type as getOsType } from "@tauri-apps/plugin-os";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
@ -10,31 +11,53 @@ interface PasteMethodProps {
grouped?: boolean;
}
const getPasteMethodOptions = (osType: string) => {
const mod = osType === "macos" ? "Cmd" : "Ctrl";
const options = [
{ value: "ctrl_v", label: `Clipboard (${mod}+V)` },
{ value: "direct", label: "Direct" },
{ value: "none", label: "None" },
];
// Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only
if (osType === "windows" || osType === "linux") {
options.push(
{ value: "ctrl_shift_v", label: "Clipboard (Ctrl+Shift+V)" },
{ value: "shift_insert", label: "Clipboard (Shift+Insert)" },
);
}
return options;
};
export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const [osType, setOsType] = useState<string>("unknown");
const getPasteMethodOptions = (osType: string) => {
const mod = osType === "macos" ? "Cmd" : "Ctrl";
const options = [
{
value: "ctrl_v",
label: t("settings.advanced.pasteMethod.options.clipboard", {
modifier: mod,
}),
},
{
value: "direct",
label: t("settings.advanced.pasteMethod.options.direct"),
},
{
value: "none",
label: t("settings.advanced.pasteMethod.options.none"),
},
];
// Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only
if (osType === "windows" || osType === "linux") {
options.push(
{
value: "ctrl_shift_v",
label: t(
"settings.advanced.pasteMethod.options.clipboardCtrlShiftV",
),
},
{
value: "shift_insert",
label: t(
"settings.advanced.pasteMethod.options.clipboardShiftInsert",
),
},
);
}
return options;
};
useEffect(() => {
setOsType(getOsType());
}, []);
@ -46,8 +69,8 @@ export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
return (
<SettingContainer
title="Paste Method"
description="Choose how text is inserted. Direct: simulates typing via system input. None: skips paste, only updates history/clipboard."
title={t("settings.advanced.pasteMethod.title")}
description={t("settings.advanced.pasteMethod.description")}
descriptionMode={descriptionMode}
grouped={grouped}
tooltipPosition="bottom"

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface PostProcessingToggleProps {
export const PostProcessingToggle: React.FC<PostProcessingToggleProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const enabled = getSetting("post_process_enabled") || false;
@ -18,8 +20,8 @@ export const PostProcessingToggle: React.FC<PostProcessingToggleProps> =
checked={enabled}
onChange={(enabled) => updateSetting("post_process_enabled", enabled)}
isUpdating={isUpdating("post_process_enabled")}
label="Post Process"
description="Enable post-processing of transcribed text using language models via OpenAI Compatible API."
label={t("settings.debug.postProcessingToggle.label")}
description={t("settings.debug.postProcessingToggle.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface PushToTalkProps {
export const PushToTalk: React.FC<PushToTalkProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const pttEnabled = getSetting("push_to_talk") || false;
@ -18,8 +20,8 @@ export const PushToTalk: React.FC<PushToTalkProps> = React.memo(
checked={pttEnabled}
onChange={(enabled) => updateSetting("push_to_talk", enabled)}
isUpdating={isUpdating("push_to_talk")}
label="Push To Talk"
description="Hold to record, release to stop"
label={t("settings.general.pushToTalk.label")}
description={t("settings.general.pushToTalk.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
@ -11,6 +12,7 @@ interface RecordingRetentionPeriodProps {
export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriodProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const selectedRetentionPeriod =
@ -25,17 +27,25 @@ export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriod
};
const retentionOptions = [
{ value: "never", label: "Never" },
{ value: "preserve_limit", label: `Preserve ${historyLimit} Recordings` },
{ value: "days3", label: "After 3 Days" },
{ value: "weeks2", label: "After 2 Weeks" },
{ value: "months3", label: "After 3 Months" },
{ value: "never", label: t("settings.debug.recordingRetention.never") },
{
value: "preserve_limit",
label: t("settings.debug.recordingRetention.preserveLimit", {
count: Number(historyLimit),
}),
},
{ value: "days3", label: t("settings.debug.recordingRetention.days3") },
{ value: "weeks2", label: t("settings.debug.recordingRetention.weeks2") },
{
value: "months3",
label: t("settings.debug.recordingRetention.months3"),
},
];
return (
<SettingContainer
title="Delete Recordings"
description="Automatically delete recordings from the device"
title={t("settings.debug.recordingRetention.title")}
description={t("settings.debug.recordingRetention.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
@ -43,7 +53,7 @@ export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriod
options={retentionOptions}
selectedValue={selectedRetentionPeriod}
onSelect={handleRetentionPeriodSelect}
placeholder="Select retention period..."
placeholder={t("settings.debug.recordingRetention.placeholder")}
disabled={isUpdating("recording_retention_period")}
/>
</SettingContainer>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
@ -9,23 +10,24 @@ interface ShowOverlayProps {
grouped?: boolean;
}
const overlayOptions = [
{ value: "none", label: "None" },
{ value: "bottom", label: "Bottom" },
{ value: "top", label: "Top" },
];
export const ShowOverlay: React.FC<ShowOverlayProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const overlayOptions = [
{ value: "none", label: t("settings.advanced.overlay.options.none") },
{ value: "bottom", label: t("settings.advanced.overlay.options.bottom") },
{ value: "top", label: t("settings.advanced.overlay.options.top") },
];
const selectedPosition = (getSetting("overlay_position") ||
"bottom") as OverlayPosition;
return (
<SettingContainer
title="Overlay Position"
description="Display visual feedback overlay during recording and transcription. On Linux 'None' is recommended."
title={t("settings.advanced.overlay.title")}
description={t("settings.advanced.overlay.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -9,6 +10,7 @@ interface StartHiddenProps {
export const StartHidden: React.FC<StartHiddenProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const startHidden = getSetting("start_hidden") ?? false;
@ -18,8 +20,8 @@ export const StartHidden: React.FC<StartHiddenProps> = React.memo(
checked={startHidden}
onChange={(enabled) => updateSetting("start_hidden", enabled)}
isUpdating={isUpdating("start_hidden")}
label="Start Hidden"
description="Launch to system tray without opening the window."
label={t("settings.advanced.startHidden.label")}
description={t("settings.advanced.startHidden.description")}
descriptionMode={descriptionMode}
grouped={grouped}
tooltipPosition="bottom"

View file

@ -1,4 +1,5 @@
import React, { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -17,6 +18,7 @@ const unsupportedTranslationModels = [
export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const { currentModel, loadCurrentModel, models } = useModels();
@ -29,11 +31,16 @@ export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
const currentModelDisplayName = models.find(
(model) => model.id === currentModel,
)?.name;
return `Translation is not supported by the ${currentModelDisplayName} model.`;
return t(
"settings.advanced.translateToEnglish.descriptionUnsupported",
{
model: currentModelDisplayName,
},
);
}
return "Automatically translate speech from other languages to English during transcription.";
}, [models, currentModel, isDisabledTranslation]);
return t("settings.advanced.translateToEnglish.description");
}, [t, models, currentModel, isDisabledTranslation]);
// Listen for model state changes to update UI reactively
useEffect(() => {
@ -52,7 +59,7 @@ export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
onChange={(enabled) => updateSetting("translate_to_english", enabled)}
isUpdating={isUpdating("translate_to_english")}
disabled={isDisabledTranslation}
label="Translate to English"
label={t("settings.advanced.translateToEnglish.label")}
description={description}
descriptionMode={descriptionMode}
grouped={grouped}

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
@ -11,6 +12,7 @@ export const UpdateChecksToggle: React.FC<UpdateChecksToggleProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const updateChecksEnabled = getSetting("update_checks_enabled") ?? true;
@ -19,8 +21,8 @@ export const UpdateChecksToggle: React.FC<UpdateChecksToggleProps> = ({
checked={updateChecksEnabled}
onChange={(enabled) => updateSetting("update_checks_enabled", enabled)}
isUpdating={isUpdating("update_checks_enabled")}
label="Check for Updates"
description="Allow Handy to automatically check for updates and enable manual checks from the footer or tray menu."
label={t("settings.debug.updateChecks.label")}
description={t("settings.debug.updateChecks.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,10 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Slider } from "../ui/Slider";
import { useSettings } from "../../hooks/useSettings";
export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
disabled = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateSetting } = useSettings();
const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5;
@ -17,8 +19,8 @@ export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
min={0}
max={1}
step={0.1}
label="Volume"
description="Adjust the volume of audio feedback sounds"
label={t("settings.sound.volume.title")}
description={t("settings.sound.volume.description")}
descriptionMode="tooltip"
grouped
formatValue={(value) => `${Math.round(value * 100)}%`}

View file

@ -1,12 +1,15 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { getVersion } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { SettingContainer } from "../../ui/SettingContainer";
import { Button } from "../../ui/Button";
import { AppDataDirectory } from "../AppDataDirectory";
import { AppLanguageSelector } from "../AppLanguageSelector";
export const AboutSettings: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState("");
useEffect(() => {
@ -33,20 +36,22 @@ export const AboutSettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="About">
<SettingsGroup title={t("settings.about.title")}>
<AppLanguageSelector descriptionMode="tooltip" grouped={true} />
<SettingContainer
title="Version"
description="Current version of Handy"
title={t("settings.about.version.title")}
description={t("settings.about.version.description")}
grouped={true}
>
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="text-sm font-mono">v{version}</span>
</SettingContainer>
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
<SettingContainer
title="Source Code"
description="View source code and contribute"
title={t("settings.about.sourceCode.title")}
description={t("settings.about.sourceCode.description")}
grouped={true}
>
<Button
@ -54,31 +59,30 @@ export const AboutSettings: React.FC = () => {
size="md"
onClick={() => openUrl("https://github.com/cjpais/Handy")}
>
View on GitHub
{t("settings.about.sourceCode.button")}
</Button>
</SettingContainer>
<SettingContainer
title="Support Development"
description="Help us continue building Handy"
title={t("settings.about.supportDevelopment.title")}
description={t("settings.about.supportDevelopment.description")}
grouped={true}
>
<Button variant="primary" size="md" onClick={handleDonateClick}>
Donate
{t("settings.about.supportDevelopment.button")}
</Button>
</SettingContainer>
</SettingsGroup>
<SettingsGroup title="Acknowledgments">
<SettingsGroup title={t("settings.about.acknowledgments.title")}>
<SettingContainer
title="Whisper.cpp"
description="High-performance inference of OpenAI's Whisper automatic speech recognition model"
title={t("settings.about.acknowledgments.whisper.title")}
description={t("settings.about.acknowledgments.whisper.description")}
grouped={true}
layout="stacked"
>
<div className="text-sm text-mid-gray">
Handy uses Whisper.cpp for fast, local speech-to-text processing.
Thanks to the amazing work by Georgi Gerganov and contributors.
{t("settings.about.acknowledgments.whisper.details")}
</div>
</SettingContainer>
</SettingsGroup>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ShowOverlay } from "../ShowOverlay";
import { TranslateToEnglish } from "../TranslateToEnglish";
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
@ -10,9 +11,10 @@ import { PasteMethodSetting } from "../PasteMethod";
import { ClipboardHandlingSetting } from "../ClipboardHandling";
export const AdvancedSettings: React.FC = () => {
const { t } = useTranslation();
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Advanced">
<SettingsGroup title={t("settings.advanced.title")}>
<StartHidden descriptionMode="tooltip" grouped={true} />
<AutostartToggle descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingContainer } from "../../ui/SettingContainer";
interface DebugPathsProps {
@ -10,6 +11,8 @@ export const DebugPaths: React.FC<DebugPathsProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { t } = useTranslation();
return (
<SettingContainer
title="Debug Paths"
@ -19,15 +22,24 @@ export const DebugPaths: React.FC<DebugPathsProps> = ({
>
<div className="text-sm text-gray-600 space-y-2">
<div>
<span className="font-medium">App Data:</span>{" "}
<span className="font-medium">
{t("settings.debug.paths.appData")}
</span>{" "}
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="font-mono text-xs">%APPDATA%/handy</span>
</div>
<div>
<span className="font-medium">Models:</span>{" "}
<span className="font-medium">
{t("settings.debug.paths.models")}
</span>{" "}
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="font-mono text-xs">%APPDATA%/handy/models</span>
</div>
<div>
<span className="font-medium">Settings:</span>{" "}
<span className="font-medium">
{t("settings.debug.paths.settings")}
</span>{" "}
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="font-mono text-xs">
%APPDATA%/handy/settings_store.json
</span>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { type } from "@tauri-apps/plugin-os";
import { WordCorrectionThreshold } from "./WordCorrectionThreshold";
import { LogDirectory } from "./LogDirectory";
@ -17,19 +18,20 @@ import { UpdateChecksToggle } from "../UpdateChecksToggle";
import { useSettings } from "../../../hooks/useSettings";
export const DebugSettings: React.FC = () => {
const { t } = useTranslation();
const { getSetting } = useSettings();
const pushToTalk = getSetting("push_to_talk");
const isLinux = type() === "linux";
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Debug">
<SettingsGroup title={t("settings.debug.title")}>
<LogDirectory grouped={true} />
<LogLevelSelector grouped={true} />
<UpdateChecksToggle descriptionMode="tooltip" grouped={true} />
<SoundPicker
label="Sound Theme"
description="Choose a sound theme for recording start and stop feedback"
label={t("settings.debug.soundTheme.label")}
description={t("settings.debug.soundTheme.description")}
/>
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<HistoryLimit descriptionMode="tooltip" grouped={true} />

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { commands } from "@/bindings";
import { SettingContainer } from "../../ui/SettingContainer";
import { Button } from "../../ui/Button";
@ -12,6 +13,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { t } = useTranslation();
const [logDir, setLogDir] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -50,8 +52,8 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
return (
<SettingContainer
title="Log Directory"
description="Location on disk where Handy writes rotated log files"
title={t("settings.debug.logDirectory.title")}
description={t("settings.debug.logDirectory.description")}
descriptionMode={descriptionMode}
grouped={grouped}
layout="stacked"
@ -62,7 +64,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
</div>
) : error ? (
<div className="p-3 bg-red-50 border border-red-200 rounded text-xs text-red-600">
Error loading log directory: {error}
{t("errors.loadDirectory", { error })}
</div>
) : (
<div className="flex items-center gap-2">
@ -76,7 +78,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
disabled={!logDir}
className="px-3 py-2"
>
Open
{t("common.open")}
</Button>
</div>
)}

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingContainer } from "../../ui/SettingContainer";
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
import { useSettings } from "../../../hooks/useSettings";
@ -21,6 +22,7 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { t } = useTranslation();
const { settings, updateSetting, isUpdating } = useSettings();
const currentLevel = settings?.log_level ?? "debug";
@ -36,8 +38,8 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
return (
<SettingContainer
title="Log Level"
description="Choose how verbose Handy should be while logging to disk"
title={t("settings.debug.logLevel.title")}
description={t("settings.debug.logLevel.description")}
descriptionMode={descriptionMode}
grouped={grouped}
layout="horizontal"

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Slider } from "../../ui/Slider";
import { useSettings } from "../../../hooks/useSettings";
@ -10,6 +11,7 @@ interface WordCorrectionThresholdProps {
export const WordCorrectionThreshold: React.FC<
WordCorrectionThresholdProps
> = ({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
const handleThresholdChange = (value: number) => {
@ -22,8 +24,8 @@ export const WordCorrectionThreshold: React.FC<
onChange={handleThresholdChange}
min={0.0}
max={1.0}
label="Word Correction Threshold"
description="Controls how aggressively custom words are applied. Lower values mean fewer corrections will be made, higher values mean more corrections. Range: 0 (least aggressive) to 1 (most aggressive)."
label={t("settings.debug.wordCorrectionThreshold.title")}
description={t("settings.debug.wordCorrectionThreshold.description")}
descriptionMode={descriptionMode}
grouped={grouped}
/>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { MicrophoneSelector } from "../MicrophoneSelector";
import { LanguageSelector } from "../LanguageSelector";
import { HandyShortcut } from "../HandyShortcut";
@ -10,15 +11,16 @@ import { useSettings } from "../../../hooks/useSettings";
import { VolumeSlider } from "../VolumeSlider";
export const GeneralSettings: React.FC = () => {
const { t } = useTranslation();
const { audioFeedbackEnabled } = useSettings();
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="General">
<SettingsGroup title={t("settings.general.title")}>
<HandyShortcut shortcutId="transcribe" grouped={true} />
<LanguageSelector descriptionMode="tooltip" grouped={true} />
<PushToTalk descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
<SettingsGroup title="Sound">
<SettingsGroup title={t("settings.sound.title")}>
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
<AudioFeedback descriptionMode="tooltip" grouped={true} />
<OutputDeviceSelector

View file

@ -1,31 +1,36 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AudioPlayer } from "../../ui/AudioPlayer";
import { Button } from "../../ui/Button";
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { commands, type HistoryEntry } from "@/bindings";
import { formatDateTime } from "@/utils/dateFormat";
interface OpenRecordingsButtonProps {
onClick: () => void;
label: string;
}
const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
onClick,
label,
}) => (
<Button
onClick={onClick}
variant="secondary"
size="sm"
className="flex items-center gap-2"
title="Open recordings folder"
title={label}
>
<FolderOpen className="w-4 h-4" />
<span>Open Recordings Folder</span>
<span>{label}</span>
</Button>
);
export const HistorySettings: React.FC = () => {
const { t } = useTranslation();
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
@ -121,14 +126,17 @@ export const HistorySettings: React.FC = () => {
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
{t("settings.history.title")}
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
<OpenRecordingsButton
onClick={openRecordingsFolder}
label={t("settings.history.openFolder")}
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
Loading history...
{t("settings.history.loading")}
</div>
</div>
</div>
@ -143,14 +151,17 @@ export const HistorySettings: React.FC = () => {
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
{t("settings.history.title")}
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
<OpenRecordingsButton
onClick={openRecordingsFolder}
label={t("settings.history.openFolder")}
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
No transcriptions yet. Start recording to build your history!
{t("settings.history.empty")}
</div>
</div>
</div>
@ -164,10 +175,13 @@ export const HistorySettings: React.FC = () => {
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
History
{t("settings.history.title")}
</h2>
</div>
<OpenRecordingsButton onClick={openRecordingsFolder} />
<OpenRecordingsButton
onClick={openRecordingsFolder}
label={t("settings.history.openFolder")}
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="divide-y divide-mid-gray/20">
@ -203,6 +217,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
getAudioUrl,
deleteAudio,
}) => {
const { t, i18n } = useTranslation();
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [showCopied, setShowCopied] = useState(false);
@ -229,15 +244,17 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
}
};
const formattedDate = formatDateTime(entry.timestamp, i18n.language);
return (
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
<div className="flex justify-between items-center">
<p className="text-sm font-medium">{entry.title}</p>
<p className="text-sm font-medium">{formattedDate}</p>
<div className="flex items-center gap-1">
<button
onClick={handleCopyText}
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
title="Copy transcription to clipboard"
title={t("settings.history.copyToClipboard")}
>
{showCopied ? (
<Check width={16} height={16} />
@ -252,7 +269,11 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"
}`}
title={entry.saved ? "Remove from saved" : "Save transcription"}
title={
entry.saved
? t("settings.history.unsave")
: t("settings.history.save")
}
>
<Star
width={16}
@ -263,7 +284,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
<button
onClick={handleDeleteEntry}
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
title="Delete entry"
title={t("settings.history.delete")}
>
<Trash2 width={16} height={16} />
</button>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCcw } from "lucide-react";
import { commands } from "@/bindings";
@ -26,13 +27,13 @@ const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({
);
const PostProcessingSettingsApiComponent: React.FC = () => {
const { t } = useTranslation();
const state = usePostProcessProviderState();
if (!state.enabled) {
return (
<DisabledNotice>
Post processing is currently disabled. Enable it in Debug settings to
configure.
{t("settings.postProcessing.disabledNotice")}
</DisabledNotice>
);
}
@ -40,8 +41,8 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
return (
<>
<SettingContainer
title="Provider"
description="Select an OpenAI-compatible provider."
title={t("settings.postProcessing.api.provider.title")}
description={t("settings.postProcessing.api.provider.description")}
descriptionMode="tooltip"
layout="horizontal"
grouped={true}
@ -57,22 +58,23 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
{state.isAppleProvider ? (
<SettingContainer
title="Apple Intelligence"
description="Runs fully on-device. No API key or network access is required."
title={t("settings.postProcessing.api.appleIntelligence.title")}
description={t(
"settings.postProcessing.api.appleIntelligence.description",
)}
descriptionMode="tooltip"
layout="stacked"
grouped={true}
>
<DisabledNotice>
Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later.
Apple Intelligence must be enabled in System Settings.
{t("settings.postProcessing.api.appleIntelligence.requirements")}
</DisabledNotice>
</SettingContainer>
) : (
<>
<SettingContainer
title="Base URL"
description="API base URL for the selected provider. Only the custom provider can be edited."
title={t("settings.postProcessing.api.baseUrl.title")}
description={t("settings.postProcessing.api.baseUrl.description")}
descriptionMode="tooltip"
layout="horizontal"
grouped={true}
@ -81,7 +83,9 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
<BaseUrlField
value={state.baseUrl}
onBlur={state.handleBaseUrlChange}
placeholder="https://api.openai.com/v1"
placeholder={t(
"settings.postProcessing.api.baseUrl.placeholder",
)}
disabled={
!state.selectedProvider?.allow_base_url_edit ||
state.isBaseUrlUpdating
@ -92,8 +96,8 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
</SettingContainer>
<SettingContainer
title="API Key"
description="API key for the selected provider."
title={t("settings.postProcessing.api.apiKey.title")}
description={t("settings.postProcessing.api.apiKey.description")}
descriptionMode="tooltip"
layout="horizontal"
grouped={true}
@ -102,7 +106,9 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
<ApiKeyField
value={state.apiKey}
onBlur={state.handleApiKeyChange}
placeholder="sk-..."
placeholder={t(
"settings.postProcessing.api.apiKey.placeholder",
)}
disabled={state.isApiKeyUpdating}
className="min-w-[320px]"
/>
@ -112,13 +118,13 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
)}
<SettingContainer
title="Model"
title={t("settings.postProcessing.api.model.title")}
description={
state.isAppleProvider
? "Provide an optional numeric token limit or keep the default on-device preset."
? t("settings.postProcessing.api.model.descriptionApple")
: state.isCustomProvider
? "Provide the model identifier expected by your custom endpoint."
: "Choose a model exposed by the selected provider."
? t("settings.postProcessing.api.model.descriptionCustom")
: t("settings.postProcessing.api.model.descriptionDefault")
}
descriptionMode="tooltip"
layout="stacked"
@ -132,10 +138,12 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
isLoading={state.isFetchingModels}
placeholder={
state.isAppleProvider
? "Apple Intelligence"
? t("settings.postProcessing.api.model.placeholderApple")
: state.modelOptions.length > 0
? "Search or select a model"
: "Type a model name"
? t(
"settings.postProcessing.api.model.placeholderWithOptions",
)
: t("settings.postProcessing.api.model.placeholderNoOptions")
}
onSelect={state.handleModelSelect}
onCreate={state.handleModelCreate}
@ -145,7 +153,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
<ResetButton
onClick={state.handleRefreshModels}
disabled={state.isFetchingModels || state.isAppleProvider}
ariaLabel="Refresh models"
ariaLabel={t("settings.postProcessing.api.model.refreshModels")}
className="flex h-10 w-10 items-center justify-center"
>
<RefreshCcw
@ -159,6 +167,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
};
const PostProcessingSettingsPromptsComponent: React.FC = () => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating, refreshSettings } =
useSettings();
const [isCreating, setIsCreating] = useState(false);
@ -259,8 +268,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
if (!enabled) {
return (
<DisabledNotice>
Post processing is currently disabled. Enable it in Debug settings to
configure.
{t("settings.postProcessing.disabledNotice")}
</DisabledNotice>
);
}
@ -273,8 +281,10 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
return (
<SettingContainer
title="Selected Prompt"
description="Select a template for refining transcriptions or create a new one. Use ${output} inside the prompt text to reference the captured transcript."
title={t("settings.postProcessing.prompts.selectedPrompt.title")}
description={t(
"settings.postProcessing.prompts.selectedPrompt.description",
)}
descriptionMode="tooltip"
layout="stacked"
grouped={true}
@ -289,7 +299,9 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
}))}
onSelect={(value) => handlePromptSelect(value)}
placeholder={
prompts.length === 0 ? "No prompts available" : "Select a prompt"
prompts.length === 0
? t("settings.postProcessing.prompts.noPrompts")
: t("settings.postProcessing.prompts.selectPrompt")
}
disabled={
isUpdating("post_process_selected_prompt_id") || isCreating
@ -302,39 +314,44 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
size="md"
disabled={isCreating}
>
Create New Prompt
{t("settings.postProcessing.prompts.createNew")}
</Button>
</div>
{!isCreating && hasPrompts && selectedPrompt && (
<div className="space-y-3">
<div className="space-y-2 flex flex-col">
<label className="text-sm font-semibold">Prompt Label</label>
<label className="text-sm font-semibold">
{t("settings.postProcessing.prompts.promptLabel")}
</label>
<Input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Enter prompt name"
placeholder={t(
"settings.postProcessing.prompts.promptLabelPlaceholder",
)}
variant="compact"
/>
</div>
<div className="space-y-2 flex flex-col">
<label className="text-sm font-semibold">
Prompt Instructions
{t("settings.postProcessing.prompts.promptInstructions")}
</label>
<Textarea
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder="Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}"
placeholder={t(
"settings.postProcessing.prompts.promptInstructionsPlaceholder",
)}
/>
<p
className="text-xs text-mid-gray/70"
dangerouslySetInnerHTML={{
__html: t("settings.postProcessing.prompts.promptTip"),
}}
/>
<p className="text-xs text-mid-gray/70">
Tip: Use{" "}
<code className="px-1 py-0.5 bg-mid-gray/20 rounded text-xs">
$&#123;output&#125;
</code>{" "}
to insert the transcribed text in your prompt.
</p>
</div>
<div className="flex gap-2 pt-2">
@ -344,7 +361,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
size="md"
disabled={!draftName.trim() || !draftText.trim() || !isDirty}
>
Update Prompt
{t("settings.postProcessing.prompts.updatePrompt")}
</Button>
<Button
onClick={() => handleDeletePrompt(selectedPromptId)}
@ -352,7 +369,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
size="md"
disabled={!selectedPromptId || prompts.length <= 1}
>
Delete Prompt
{t("settings.postProcessing.prompts.deletePrompt")}
</Button>
</div>
</div>
@ -362,8 +379,8 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
<div className="p-3 bg-mid-gray/5 rounded border border-mid-gray/20">
<p className="text-sm text-mid-gray">
{hasPrompts
? "Select a prompt above to view and edit its details."
: "Click 'Create New Prompt' above to create your first post-processing prompt."}
? t("settings.postProcessing.prompts.selectToEdit")
: t("settings.postProcessing.prompts.createFirst")}
</p>
</div>
)}
@ -372,33 +389,36 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
<div className="space-y-3">
<div className="space-y-2 block flex flex-col">
<label className="text-sm font-semibold text-text">
Prompt Label
{t("settings.postProcessing.prompts.promptLabel")}
</label>
<Input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Enter prompt name"
placeholder={t(
"settings.postProcessing.prompts.promptLabelPlaceholder",
)}
variant="compact"
/>
</div>
<div className="space-y-2 flex flex-col">
<label className="text-sm font-semibold">
Prompt Instructions
{t("settings.postProcessing.prompts.promptInstructions")}
</label>
<Textarea
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder="Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}"
placeholder={t(
"settings.postProcessing.prompts.promptInstructionsPlaceholder",
)}
/>
<p
className="text-xs text-mid-gray/70"
dangerouslySetInnerHTML={{
__html: t("settings.postProcessing.prompts.promptTip"),
}}
/>
<p className="text-xs text-mid-gray/70">
Tip: Use{" "}
<code className="px-1 py-0.5 bg-mid-gray/20 rounded text-xs">
$&#123;output&#125;
</code>{" "}
to insert the transcribed text in your prompt.
</p>
</div>
<div className="flex gap-2 pt-2">
@ -408,14 +428,14 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
size="md"
disabled={!draftName.trim() || !draftText.trim()}
>
Create Prompt
{t("settings.postProcessing.prompts.createPrompt")}
</Button>
<Button
onClick={handleCancelCreate}
variant="secondary"
size="md"
>
Cancel
{t("settings.postProcessing.prompts.cancel")}
</Button>
</div>
</div>
@ -436,13 +456,15 @@ export const PostProcessingSettingsPrompts = React.memo(
PostProcessingSettingsPrompts.displayName = "PostProcessingSettingsPrompts";
export const PostProcessingSettings: React.FC = () => {
const { t } = useTranslation();
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="API (OpenAI Compatible)">
<SettingsGroup title={t("settings.postProcessing.api.title")}>
<PostProcessingSettingsApi />
</SettingsGroup>
<SettingsGroup title="Prompt">
<SettingsGroup title={t("settings.postProcessing.prompts.title")}>
<PostProcessingSettingsPrompts />
</SettingsGroup>
</div>

View file

@ -52,6 +52,7 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
<span className="mr-2">{item.label}</span>
)}
{showSpeed && item.speed !== undefined && item.speed > 0 ? (
// eslint-disable-next-line i18next/no-literal-string
<span>{item.speed.toFixed(1)}MB/s</span>
) : showSpeed ? (
<span>Downloading...</span>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface DropdownOption {
value: string;
@ -25,6 +26,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
disabled = false,
onRefresh,
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -87,7 +89,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-y-auto">
{options.length === 0 ? (
<div className="px-2 py-1 text-sm text-mid-gray">
No options found
{t("common.noOptionsFound")}
</div>
) : (
options.map((option) => (

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { listen } from "@tauri-apps/api/event";
@ -10,6 +11,7 @@ interface UpdateCheckerProps {
}
const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
const { t } = useTranslation();
// Update checking state
const [isChecking, setIsChecking] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
@ -140,19 +142,21 @@ const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
// Update status functions
const getUpdateStatusText = () => {
if (!updateChecksEnabled) {
return "Update Checking Disabled";
return t("footer.updateCheckingDisabled");
}
if (isInstalling) {
return downloadProgress > 0 && downloadProgress < 100
? `Downloading... ${downloadProgress.toString().padStart(3)}%`
? t("footer.downloading", {
progress: downloadProgress.toString().padStart(3),
})
: downloadProgress === 100
? "Installing..."
: "Preparing...";
? t("footer.installing")
: t("footer.preparing");
}
if (isChecking) return "Checking...";
if (showUpToDate) return "Up to date";
if (updateAvailable) return "Update available";
return "Check for updates";
if (isChecking) return t("footer.checkingUpdates");
if (showUpToDate) return t("footer.upToDate");
if (updateAvailable) return t("footer.updateAvailableShort");
return t("footer.checkForUpdates");
};
const getUpdateStatusAction = () => {

84
src/i18n/index.ts Normal file
View file

@ -0,0 +1,84 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { locale } from "@tauri-apps/plugin-os";
import { LANGUAGE_METADATA } from "./languages";
// Auto-discover translation files using Vite's glob import
const localeModules = import.meta.glob<{ default: Record<string, unknown> }>(
"./locales/*/translation.json",
{ eager: true },
);
// Build resources from discovered locale files
const resources: Record<string, { translation: Record<string, unknown> }> = {};
for (const [path, module] of Object.entries(localeModules)) {
const langCode = path.match(/\.\/locales\/(.+)\/translation\.json/)?.[1];
if (langCode) {
resources[langCode] = { translation: module.default };
}
}
// Build supported languages list from discovered locales + metadata
export const SUPPORTED_LANGUAGES = Object.keys(resources)
.map((code) => {
const meta = LANGUAGE_METADATA[code];
if (!meta) {
console.warn(`Missing metadata for locale "${code}" in languages.ts`);
return { code, name: code, nativeName: code };
}
return { code, name: meta.name, nativeName: meta.nativeName };
})
.sort((a, b) => a.name.localeCompare(b.name));
export type SupportedLanguageCode = string;
// Check if a language code is supported
const getSupportedLanguage = (
langCode: string | null | undefined,
): SupportedLanguageCode | null => {
if (!langCode) return null;
const code = langCode.split("-")[0].toLowerCase();
const supported = SUPPORTED_LANGUAGES.find((lang) => lang.code === code);
return supported ? supported.code : null;
};
// Get saved language from localStorage
const getSavedLanguage = (): SupportedLanguageCode | null => {
const savedLang = localStorage.getItem("handy-app-language");
return getSupportedLanguage(savedLang);
};
// Initialize i18n with saved language or English as default
// System locale detection happens async after init
i18n.use(initReactI18next).init({
resources,
lng: getSavedLanguage() || "en",
fallbackLng: "en",
interpolation: {
escapeValue: false, // React already escapes values
},
react: {
useSuspense: false, // Disable suspense for SSR compatibility
},
});
// After init, check system locale if no saved preference
const initSystemLocale = async () => {
// Skip if user has explicitly set a language
if (getSavedLanguage()) return;
try {
const systemLocale = await locale();
const supported = getSupportedLanguage(systemLocale);
if (supported && supported !== i18n.language) {
await i18n.changeLanguage(supported);
}
} catch (e) {
console.warn("Failed to get system locale:", e);
}
};
// Run async locale detection
initSystemLocale();
export default i18n;

16
src/i18n/languages.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Language metadata for supported locales.
*
* To add a new language:
* 1. Create a new folder: src/i18n/locales/{code}/translation.json
* 2. Add an entry here with the language code, English name, and native name
*/
export const LANGUAGE_METADATA: Record<
string,
{ name: string; nativeName: string }
> = {
en: { name: "English", nativeName: "English" },
es: { name: "Spanish", nativeName: "Español" },
fr: { name: "French", nativeName: "Français" },
vi: { name: "Vietnamese", nativeName: "Tiếng Việt" },
};

View file

@ -0,0 +1,410 @@
{
"sidebar": {
"general": "General",
"advanced": "Advanced",
"postProcessing": "Post Process",
"history": "History",
"debug": "Debug",
"about": "About"
},
"onboarding": {
"subtitle": "To get started, choose a transcription model",
"recommended": "Recommended",
"download": "Download",
"downloading": "Downloading...",
"modelCard": {
"accuracy": "accuracy",
"speed": "speed"
},
"models": {
"small": {
"name": "Whisper Small",
"description": "Fast and fairly accurate."
},
"medium": {
"name": "Whisper Medium",
"description": "Good accuracy, medium speed"
},
"turbo": {
"name": "Whisper Turbo",
"description": "Balanced accuracy and speed."
},
"large": {
"name": "Whisper Large",
"description": "Good accuracy, but slow."
},
"parakeet-tdt-0.6b-v2": {
"name": "Parakeet V2",
"description": "English only. The best model for English speakers."
},
"parakeet-tdt-0.6b-v3": {
"name": "Parakeet V3",
"description": "Fast and accurate"
}
},
"errors": {
"loadModels": "Failed to load available models",
"downloadModel": "Failed to download model: {{error}}"
}
},
"modelSelector": {
"welcome": "Welcome to Handy!",
"downloadPrompt": "Download a model below to get started with transcription.",
"availableModels": "Available Models",
"downloadModels": "Download Models",
"chooseModel": "Choose a Model",
"active": "Active",
"download": "Download",
"downloadSize": "Download size",
"noModelsAvailable": "No models available",
"extracting": "Extracting {{modelName}}...",
"extractingMultiple": "Extracting {{count}} models...",
"extractingGeneric": "Extracting...",
"downloading": "Downloading {{percentage}}%",
"downloadingMultiple": "Downloading {{count}} models...",
"modelReady": "Model Ready",
"loading": "Loading {{modelName}}...",
"loadingGeneric": "Loading...",
"modelError": "Model Error",
"modelUnloaded": "Model Unloaded",
"noModelDownloadRequired": "No Model - Download Required",
"deleteModel": "Delete {{modelName}}"
},
"settings": {
"general": {
"title": "General",
"shortcut": {
"title": "Handy Shortcuts",
"description": "Configure keyboard shortcuts to trigger speech-to-text recording",
"loading": "Loading shortcuts...",
"none": "No shortcuts configured",
"notFound": "Shortcut not found",
"pressKeys": "Press keys...",
"bindings": {
"transcribe": {
"name": "Transcribe",
"description": "Converts your speech into text."
},
"cancel": {
"name": "Cancel",
"description": "Cancels the current recording."
}
},
"errors": {
"restore": "Failed to restore original shortcut",
"set": "Failed to set shortcut: {{error}}",
"reset": "Failed to reset shortcut to original value"
}
},
"language": {
"title": "Language",
"description": "Select the language for speech recognition. Auto will automatically determine the language, while selecting a specific language can improve accuracy for that language.",
"descriptionUnsupported": "Parakeet model automatically detects the language. No manual selection is needed.",
"searchPlaceholder": "Search languages...",
"noResults": "No languages found",
"auto": "Auto"
},
"pushToTalk": {
"label": "Push To Talk",
"description": "Hold to record, release to stop"
}
},
"sound": {
"title": "Sound",
"microphone": {
"title": "Microphone",
"description": "Select your preferred microphone device",
"placeholder": "Select microphone...",
"loading": "Loading..."
},
"audioFeedback": {
"label": "Audio Feedback",
"description": "Play sound when recording starts and stops"
},
"outputDevice": {
"title": "Output Device",
"description": "Select your preferred audio output device for feedback sounds",
"placeholder": "Select output device...",
"loading": "Loading..."
},
"volume": {
"title": "Volume",
"description": "Adjust the volume of audio feedback sounds"
}
},
"advanced": {
"title": "Advanced",
"startHidden": {
"label": "Start Hidden",
"description": "Launch to system tray without opening the window."
},
"autostart": {
"label": "Launch on Startup",
"description": "Automatically start Handy when you log in to your computer."
},
"overlay": {
"title": "Overlay Position",
"description": "Display visual feedback overlay during recording and transcription. On Linux 'None' is recommended.",
"options": {
"none": "None",
"bottom": "Bottom",
"top": "Top"
}
},
"pasteMethod": {
"title": "Paste Method",
"description": "Choose how text is inserted. Direct: simulates typing via system input. None: skips paste, only updates history/clipboard.",
"options": {
"clipboard": "Clipboard ({{modifier}}+V)",
"clipboardCtrlShiftV": "Clipboard (Ctrl+Shift+V)",
"clipboardShiftInsert": "Clipboard (Shift+Insert)",
"direct": "Direct",
"none": "None"
}
},
"clipboardHandling": {
"title": "Clipboard Handling",
"description": "Don't Modify Clipboard preserves your current clipboard contents after transcription. Copy to Clipboard leaves the transcription result in your clipboard after pasting.",
"options": {
"dontModify": "Don't Modify Clipboard",
"copyToClipboard": "Copy to Clipboard"
}
},
"translateToEnglish": {
"label": "Translate to English",
"description": "Automatically translate speech from other languages to English during transcription.",
"descriptionUnsupported": "Translation is not supported by the {{model}} model."
},
"modelUnload": {
"title": "Unload Model",
"description": "Automatically free GPU/CPU memory when the model hasn't been used for the specified time",
"options": {
"never": "Never",
"immediately": "Immediately",
"min2": "After 2 minutes",
"min5": "After 5 minutes",
"min10": "After 10 minutes",
"min15": "After 15 minutes",
"hour1": "After 1 hour",
"sec5": "After 5 seconds (Debug)"
}
},
"customWords": {
"title": "Custom Words",
"description": "Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list.",
"placeholder": "Add a word",
"add": "Add",
"remove": "Remove {{word}}"
}
},
"postProcessing": {
"title": "Post Process",
"disabledNotice": "Post processing is currently disabled. Enable it in Debug settings to configure.",
"api": {
"title": "API (OpenAI Compatible)",
"provider": {
"title": "Provider",
"description": "Select an OpenAI-compatible provider."
},
"appleIntelligence": {
"title": "Apple Intelligence",
"description": "Runs fully on-device. No API key or network access is required.",
"requirements": "Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later. Apple Intelligence must be enabled in System Settings."
},
"baseUrl": {
"title": "Base URL",
"description": "API base URL for the selected provider. Only the custom provider can be edited.",
"placeholder": "https://api.openai.com/v1"
},
"apiKey": {
"title": "API Key",
"description": "API key for the selected provider.",
"placeholder": "sk-..."
},
"model": {
"title": "Model",
"descriptionApple": "Provide an optional numeric token limit or keep the default on-device preset.",
"descriptionCustom": "Provide the model identifier expected by your custom endpoint.",
"descriptionDefault": "Choose a model exposed by the selected provider.",
"placeholderApple": "Apple Intelligence",
"placeholderWithOptions": "Search or select a model",
"placeholderNoOptions": "Type a model name",
"refreshModels": "Refresh models"
}
},
"prompts": {
"title": "Prompt",
"selectedPrompt": {
"title": "Selected Prompt",
"description": "Select a template for refining transcriptions or create a new one. Use ${output} inside the prompt text to reference the captured transcript."
},
"noPrompts": "No prompts available",
"selectPrompt": "Select a prompt",
"createNew": "Create New Prompt",
"promptLabel": "Prompt Label",
"promptLabelPlaceholder": "Enter prompt name",
"promptInstructions": "Prompt Instructions",
"promptInstructionsPlaceholder": "Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}",
"promptTip": "Tip: Use <code>${output}</code> to insert the transcribed text in your prompt.",
"updatePrompt": "Update Prompt",
"deletePrompt": "Delete Prompt",
"createPrompt": "Create Prompt",
"cancel": "Cancel",
"selectToEdit": "Select a prompt above to view and edit its details.",
"createFirst": "Click 'Create New Prompt' above to create your first post-processing prompt."
}
},
"history": {
"title": "History",
"openFolder": "Open Recordings Folder",
"loading": "Loading history...",
"empty": "No transcriptions yet. Start recording to build your history!",
"copyToClipboard": "Copy transcription to clipboard",
"save": "Save transcription",
"unsave": "Remove from saved",
"delete": "Delete entry",
"deleteError": "Failed to delete entry. Please try again."
},
"debug": {
"title": "Debug",
"logDirectory": {
"title": "Log Directory",
"description": "Location where log files are stored"
},
"logLevel": {
"title": "Log Level",
"description": "Set the verbosity of logging"
},
"updateChecks": {
"label": "Check for Updates",
"description": "Automatically check for new versions of Handy"
},
"soundTheme": {
"label": "Sound Theme",
"description": "Choose a sound theme for recording start and stop feedback"
},
"wordCorrectionThreshold": {
"title": "Word Correction Threshold",
"description": "Sensitivity for custom word corrections"
},
"historyLimit": {
"title": "History Limit",
"description": "Maximum number of history entries to keep",
"entries": "entries"
},
"recordingRetention": {
"title": "Recording Retention",
"description": "How long to keep audio recordings",
"never": "Never",
"preserveLimit": "Preserve {{count}} Recordings",
"days3": "After 3 Days",
"weeks2": "After 2 Weeks",
"months3": "After 3 Months",
"placeholder": "Select retention period..."
},
"alwaysOnMicrophone": {
"label": "Always-On Microphone",
"description": "Keep microphone active for faster response"
},
"clamshellMicrophone": {
"title": "Clamshell Microphone",
"description": "Microphone to use when laptop lid is closed"
},
"postProcessingToggle": {
"label": "Post Processing",
"description": "Enable AI-powered text refinement after transcription"
},
"muteWhileRecording": {
"label": "Mute While Recording",
"description": "Mute system audio during recording"
},
"appendTrailingSpace": {
"label": "Append Trailing Space",
"description": "Add a space after pasted transcription"
},
"paths": {
"appData": "App Data:",
"models": "Models:",
"settings": "Settings:"
}
},
"about": {
"title": "About",
"version": {
"title": "Version",
"description": "Current version of Handy"
},
"appDataDirectory": {
"title": "App Data Directory",
"description": "Location where Handy stores its data"
},
"sourceCode": {
"title": "Source Code",
"description": "View source code and contribute",
"button": "View on GitHub"
},
"supportDevelopment": {
"title": "Support Development",
"description": "Help us continue building Handy",
"button": "Donate"
},
"acknowledgments": {
"title": "Acknowledgments",
"whisper": {
"title": "Whisper.cpp",
"description": "High-performance inference of OpenAI's Whisper automatic speech recognition model",
"details": "Handy uses Whisper.cpp for fast, local speech-to-text processing. Thanks to the amazing work by Georgi Gerganov and contributors."
}
}
}
},
"footer": {
"downloadingModel": "Downloading {{model}}...",
"checkingUpdates": "Checking for updates...",
"updateAvailable": "Update available: {{version}}",
"updateAvailableShort": "Update available",
"upToDate": "Up to date",
"downloadUpdate": "Download Update",
"restart": "Restart",
"updateCheckingDisabled": "Update Checking Disabled",
"downloading": "Downloading... {{progress}}%",
"installing": "Installing...",
"preparing": "Preparing...",
"checkForUpdates": "Check for updates"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"reset": "Reset",
"add": "Add",
"remove": "Remove",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"update": "Update",
"close": "Close",
"open": "Open",
"default": "Default",
"enabled": "Enabled",
"disabled": "Disabled",
"on": "On",
"off": "Off",
"yes": "Yes",
"no": "No",
"noOptionsFound": "No options found"
},
"accessibility": {
"permissionsRequired": "Accessibility Permissions Required",
"permissionsDescription": "Handy needs accessibility permissions to type transcribed text.",
"openSettings": "Open System Settings",
"dismiss": "Dismiss"
},
"errors": {
"loadDirectory": "Error loading directory: {{error}}"
},
"appLanguage": {
"title": "Application Language",
"description": "Change the language of the Handy interface"
}
}

View file

@ -0,0 +1,403 @@
{
"sidebar": {
"general": "General",
"advanced": "Avanzado",
"postProcessing": "Post Proceso",
"history": "Historial",
"debug": "Depuración",
"about": "Acerca de"
},
"onboarding": {
"subtitle": "Para comenzar, elige un modelo de transcripción",
"recommended": "Recomendado",
"download": "Descargar",
"downloading": "Descargando...",
"modelCard": {
"accuracy": "precisión",
"speed": "velocidad"
},
"models": {
"small": {
"name": "Whisper Small",
"description": "Rápido y bastante preciso."
},
"medium": {
"name": "Whisper Medium",
"description": "Buena precisión, velocidad media"
},
"turbo": {
"name": "Whisper Turbo",
"description": "Equilibrio entre precisión y velocidad."
},
"large": {
"name": "Whisper Large",
"description": "Buena precisión, pero lento."
},
"parakeet-tdt-0.6b-v2": {
"name": "Parakeet V2",
"description": "Solo inglés. El mejor modelo para hablantes de inglés."
},
"parakeet-tdt-0.6b-v3": {
"name": "Parakeet V3",
"description": "Rápido y preciso"
}
},
"errors": {
"loadModels": "Error al cargar los modelos disponibles",
"downloadModel": "Error al descargar el modelo: {{error}}"
}
},
"modelSelector": {
"welcome": "¡Bienvenido a Handy!",
"downloadPrompt": "Descarga un modelo a continuación para comenzar con la transcripción.",
"availableModels": "Modelos Disponibles",
"downloadModels": "Descargar Modelos",
"chooseModel": "Elegir un Modelo",
"active": "Activo",
"download": "Descargar",
"downloadSize": "Tamaño de descarga",
"noModelsAvailable": "No hay modelos disponibles",
"extracting": "Extrayendo {{modelName}}...",
"extractingMultiple": "Extrayendo {{count}} modelos...",
"extractingGeneric": "Extrayendo...",
"downloading": "Descargando {{percentage}}%",
"downloadingMultiple": "Descargando {{count}} modelos...",
"modelReady": "Modelo Listo",
"loading": "Cargando {{modelName}}...",
"loadingGeneric": "Cargando...",
"modelError": "Error del Modelo",
"modelUnloaded": "Modelo Descargado",
"noModelDownloadRequired": "Sin Modelo - Descarga Requerida",
"deleteModel": "Eliminar {{modelName}}"
},
"settings": {
"general": {
"title": "General",
"shortcut": {
"title": "Atajos de Handy",
"description": "Configura atajos de teclado para activar la grabación de voz a texto",
"loading": "Cargando atajos...",
"none": "No hay atajos configurados",
"notFound": "Atajo no encontrado",
"pressKeys": "Presiona teclas...",
"bindings": {
"transcribe": {
"name": "Transcribir",
"description": "Convierte tu voz en texto."
},
"cancel": {
"name": "Cancelar",
"description": "Cancela la grabación actual."
}
},
"errors": {
"restore": "Error al restaurar el atajo original",
"set": "Error al configurar el atajo: {{error}}",
"reset": "Error al restablecer el atajo al valor original"
}
},
"language": {
"title": "Idioma",
"description": "Selecciona el idioma para el reconocimiento de voz. Auto detectará automáticamente el idioma, mientras que seleccionar un idioma específico puede mejorar la precisión para ese idioma.",
"descriptionUnsupported": "El modelo Parakeet detecta automáticamente el idioma. No se necesita selección manual.",
"searchPlaceholder": "Buscar idiomas...",
"noResults": "No se encontraron idiomas",
"auto": "Auto"
},
"pushToTalk": {
"label": "Presionar para Hablar",
"description": "Mantén presionado para grabar, suelta para detener"
}
},
"sound": {
"title": "Sonido",
"microphone": {
"title": "Micrófono",
"description": "Selecciona tu dispositivo de micrófono preferido",
"placeholder": "Seleccionar micrófono...",
"loading": "Cargando..."
},
"audioFeedback": {
"label": "Retroalimentación de Audio",
"description": "Reproducir sonido cuando la grabación inicia y se detiene"
},
"outputDevice": {
"title": "Dispositivo de Salida",
"description": "Selecciona tu dispositivo de salida de audio preferido para los sonidos de retroalimentación",
"placeholder": "Seleccionar dispositivo de salida...",
"loading": "Cargando..."
},
"volume": {
"title": "Volumen",
"description": "Ajusta el volumen de los sonidos de retroalimentación de audio"
}
},
"advanced": {
"title": "Avanzado",
"startHidden": {
"label": "Iniciar Oculto",
"description": "Lanzar en la bandeja del sistema sin abrir la ventana."
},
"autostart": {
"label": "Iniciar al Arranque",
"description": "Iniciar Handy automáticamente cuando inicies sesión en tu computadora."
},
"overlay": {
"title": "Posición de Superposición",
"description": "Mostrar superposición de retroalimentación visual durante la grabación y transcripción. En Linux se recomienda 'Ninguna'.",
"options": {
"none": "Ninguna",
"bottom": "Abajo",
"top": "Arriba"
}
},
"pasteMethod": {
"title": "Método de Pegado",
"description": "Elige cómo se inserta el texto. Directo: simula escritura mediante entrada del sistema. Ninguno: omite el pegado, solo actualiza historial/portapapeles.",
"options": {
"clipboard": "Portapapeles ({{modifier}}+V)",
"clipboardCtrlShiftV": "Portapapeles (Ctrl+Shift+V)",
"clipboardShiftInsert": "Portapapeles (Shift+Insert)",
"direct": "Directo",
"none": "Ninguno"
}
},
"clipboardHandling": {
"title": "Manejo del Portapapeles",
"description": "No Modificar Portapapeles conserva el contenido actual de tu portapapeles después de la transcripción. Copiar al Portapapeles deja el resultado de la transcripción en tu portapapeles después de pegar.",
"options": {
"dontModify": "No Modificar Portapapeles",
"copyToClipboard": "Copiar al Portapapeles"
}
},
"translateToEnglish": {
"label": "Traducir al Inglés",
"description": "Traducir automáticamente el habla de otros idiomas al inglés durante la transcripción.",
"descriptionUnsupported": "La traducción no es compatible con el modelo {{model}}."
},
"modelUnload": {
"title": "Descargar Modelo",
"description": "Liberar automáticamente la memoria GPU/CPU cuando el modelo no se ha usado durante el tiempo especificado",
"options": {
"never": "Nunca",
"immediately": "Inmediatamente",
"min2": "Después de 2 minutos",
"min5": "Después de 5 minutos",
"min10": "Después de 10 minutos",
"min15": "Después de 15 minutos",
"hour1": "Después de 1 hora",
"sec5": "Después de 5 segundos (Depuración)"
}
},
"customWords": {
"title": "Palabras Personalizadas",
"description": "Agrega palabras que a menudo se escuchan mal o se escriben incorrectamente durante la transcripción. El sistema corregirá automáticamente palabras similares para que coincidan con tu lista.",
"placeholder": "Agregar una palabra",
"add": "Agregar",
"remove": "Eliminar {{word}}"
}
},
"postProcessing": {
"title": "Post Proceso",
"disabledNotice": "El post procesamiento está actualmente deshabilitado. Habilítalo en la configuración de Depuración para configurarlo.",
"api": {
"title": "API (Compatible con OpenAI)",
"provider": {
"title": "Proveedor",
"description": "Selecciona un proveedor compatible con OpenAI."
},
"appleIntelligence": {
"title": "Apple Intelligence",
"description": "Se ejecuta completamente en el dispositivo. No se requiere clave API ni acceso a la red.",
"requirements": "Requiere un Mac con Apple Silicon ejecutando macOS Tahoe (26.0) o posterior. Apple Intelligence debe estar habilitado en Ajustes del Sistema."
},
"baseUrl": {
"title": "URL Base",
"description": "URL base de la API para el proveedor seleccionado. Solo se puede editar el proveedor personalizado.",
"placeholder": "https://api.openai.com/v1"
},
"apiKey": {
"title": "Clave API",
"description": "Clave API para el proveedor seleccionado.",
"placeholder": "sk-..."
},
"model": {
"title": "Modelo",
"descriptionApple": "Proporciona un límite de tokens numérico opcional o mantén el preajuste predeterminado en el dispositivo.",
"descriptionCustom": "Proporciona el identificador del modelo esperado por tu endpoint personalizado.",
"descriptionDefault": "Elige un modelo expuesto por el proveedor seleccionado.",
"placeholderApple": "Apple Intelligence",
"placeholderWithOptions": "Buscar o seleccionar un modelo",
"placeholderNoOptions": "Escribe un nombre de modelo",
"refreshModels": "Actualizar modelos"
}
},
"prompts": {
"title": "Prompt",
"selectedPrompt": {
"title": "Prompt Seleccionado",
"description": "Selecciona una plantilla para refinar las transcripciones o crea una nueva. Usa ${output} dentro del texto del prompt para hacer referencia a la transcripción capturada."
},
"noPrompts": "No hay prompts disponibles",
"selectPrompt": "Seleccionar un prompt",
"createNew": "Crear Nuevo Prompt",
"promptLabel": "Etiqueta del Prompt",
"promptLabelPlaceholder": "Ingresa el nombre del prompt",
"promptInstructions": "Instrucciones del Prompt",
"promptInstructionsPlaceholder": "Escribe las instrucciones para ejecutar después de la transcripción. Ejemplo: Mejora la gramática y claridad del siguiente texto: ${output}",
"promptTip": "Consejo: Usa <code>${output}</code> para insertar el texto transcrito en tu prompt.",
"updatePrompt": "Actualizar Prompt",
"deletePrompt": "Eliminar Prompt",
"createPrompt": "Crear Prompt",
"cancel": "Cancelar",
"selectToEdit": "Selecciona un prompt arriba para ver y editar sus detalles.",
"createFirst": "Haz clic en 'Crear Nuevo Prompt' arriba para crear tu primer prompt de post procesamiento."
}
},
"history": {
"title": "Historial",
"openFolder": "Abrir Carpeta de Grabaciones",
"loading": "Cargando historial...",
"empty": "Aún no hay transcripciones. ¡Comienza a grabar para crear tu historial!",
"copyToClipboard": "Copiar transcripción al portapapeles",
"save": "Guardar transcripción",
"unsave": "Eliminar de guardados",
"delete": "Eliminar entrada",
"deleteError": "Error al eliminar la entrada. Por favor, intenta de nuevo."
},
"debug": {
"title": "Depuración",
"logDirectory": {
"title": "Directorio de Registros",
"description": "Ubicación donde se almacenan los archivos de registro"
},
"logLevel": {
"title": "Nivel de Registro",
"description": "Establece el nivel de detalle del registro"
},
"updateChecks": {
"label": "Buscar Actualizaciones",
"description": "Buscar automáticamente nuevas versiones de Handy"
},
"soundTheme": {
"label": "Tema de Sonido",
"description": "Elige un tema de sonido para la retroalimentación de inicio y parada de grabación"
},
"wordCorrectionThreshold": {
"title": "Umbral de Corrección de Palabras",
"description": "Sensibilidad para correcciones de palabras personalizadas"
},
"historyLimit": {
"title": "Límite de Historial",
"description": "Número máximo de entradas de historial a conservar"
},
"recordingRetention": {
"title": "Retención de Grabaciones",
"description": "Cuánto tiempo conservar las grabaciones de audio"
},
"alwaysOnMicrophone": {
"label": "Micrófono Siempre Activo",
"description": "Mantener el micrófono activo para una respuesta más rápida"
},
"clamshellMicrophone": {
"title": "Micrófono en Modo Clamshell",
"description": "Micrófono a usar cuando la tapa del portátil está cerrada"
},
"postProcessingToggle": {
"label": "Post Procesamiento",
"description": "Habilitar refinamiento de texto impulsado por IA después de la transcripción"
},
"muteWhileRecording": {
"label": "Silenciar Durante la Grabación",
"description": "Silenciar el audio del sistema durante la grabación"
},
"appendTrailingSpace": {
"label": "Agregar Espacio Final",
"description": "Agregar un espacio después de la transcripción pegada"
},
"paths": {
"appData": "Datos de la Aplicación:",
"models": "Modelos:",
"settings": "Configuración:"
}
},
"about": {
"title": "Acerca de",
"version": {
"title": "Versión",
"description": "Versión actual de Handy"
},
"appDataDirectory": {
"title": "Directorio de Datos de la Aplicación",
"description": "Ubicación donde Handy almacena sus datos"
},
"sourceCode": {
"title": "Código Fuente",
"description": "Ver código fuente y contribuir",
"button": "Ver en GitHub"
},
"supportDevelopment": {
"title": "Apoyar el Desarrollo",
"description": "Ayúdanos a continuar construyendo Handy",
"button": "Donar"
},
"acknowledgments": {
"title": "Reconocimientos",
"whisper": {
"title": "Whisper.cpp",
"description": "Inferencia de alto rendimiento del modelo de reconocimiento automático de voz Whisper de OpenAI",
"details": "Handy usa Whisper.cpp para procesamiento de voz a texto rápido y local. Gracias al increíble trabajo de Georgi Gerganov y colaboradores."
}
}
}
},
"footer": {
"downloadingModel": "Descargando {{model}}...",
"checkingUpdates": "Buscando actualizaciones...",
"updateAvailable": "Actualización disponible: {{version}}",
"updateAvailableShort": "Actualización disponible",
"upToDate": "Actualizado",
"downloadUpdate": "Descargar Actualización",
"restart": "Reiniciar",
"updateCheckingDisabled": "Búsqueda de Actualizaciones Deshabilitada",
"downloading": "Descargando... {{progress}}%",
"installing": "Instalando...",
"preparing": "Preparando...",
"checkForUpdates": "Buscar actualizaciones"
},
"common": {
"loading": "Cargando...",
"save": "Guardar",
"cancel": "Cancelar",
"reset": "Restablecer",
"add": "Agregar",
"remove": "Eliminar",
"delete": "Eliminar",
"edit": "Editar",
"create": "Crear",
"update": "Actualizar",
"close": "Cerrar",
"open": "Abrir",
"default": "Predeterminado",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"on": "Activado",
"off": "Desactivado",
"yes": "Sí",
"no": "No",
"noOptionsFound": "No se encontraron opciones"
},
"accessibility": {
"permissionsRequired": "Se Requieren Permisos de Accesibilidad",
"permissionsDescription": "Handy necesita permisos de accesibilidad para escribir texto transcrito.",
"openSettings": "Abrir Ajustes del Sistema",
"dismiss": "Descartar"
},
"errors": {
"loadDirectory": "Error al cargar el directorio: {{error}}"
},
"appLanguage": {
"title": "Idioma de la aplicación",
"description": "Cambia el idioma de la interfaz de Handy"
}
}

View file

@ -0,0 +1,404 @@
{
"_comment": "French translation for Handy. Contribute at: https://github.com/cjpais/Handy",
"sidebar": {
"general": "Général",
"advanced": "Avancé",
"postProcessing": "Post-traitement",
"history": "Historique",
"debug": "Débogage",
"about": "À propos"
},
"onboarding": {
"subtitle": "Pour commencer, choisissez un modèle de transcription",
"recommended": "Recommandé",
"download": "Télécharger",
"downloading": "Téléchargement...",
"modelCard": {
"accuracy": "précision",
"speed": "vitesse"
},
"models": {
"small": {
"name": "Whisper Small",
"description": "Rapide et assez précis."
},
"medium": {
"name": "Whisper Medium",
"description": "Bonne précision, vitesse moyenne"
},
"turbo": {
"name": "Whisper Turbo",
"description": "Équilibre entre précision et vitesse."
},
"large": {
"name": "Whisper Large",
"description": "Bonne précision, mais lent."
},
"parakeet-tdt-0.6b-v2": {
"name": "Parakeet V2",
"description": "Anglais uniquement. Le meilleur modèle pour les anglophones."
},
"parakeet-tdt-0.6b-v3": {
"name": "Parakeet V3",
"description": "Rapide et précis"
}
},
"errors": {
"loadModels": "Échec du chargement des modèles disponibles",
"downloadModel": "Échec du téléchargement du modèle : {{error}}"
}
},
"modelSelector": {
"welcome": "Bienvenue sur Handy !",
"downloadPrompt": "Téléchargez un modèle ci-dessous pour commencer la transcription.",
"availableModels": "Modèles Disponibles",
"downloadModels": "Télécharger des Modèles",
"chooseModel": "Choisir un Modèle",
"active": "Actif",
"download": "Télécharger",
"downloadSize": "Taille du téléchargement",
"noModelsAvailable": "Aucun modèle disponible",
"extracting": "Extraction de {{modelName}}...",
"extractingMultiple": "Extraction de {{count}} modèles...",
"extractingGeneric": "Extraction...",
"downloading": "Téléchargement {{percentage}}%",
"downloadingMultiple": "Téléchargement de {{count}} modèles...",
"modelReady": "Modèle Prêt",
"loading": "Chargement de {{modelName}}...",
"loadingGeneric": "Chargement...",
"modelError": "Erreur du Modèle",
"modelUnloaded": "Modèle Déchargé",
"noModelDownloadRequired": "Aucun Modèle - Téléchargement Requis",
"deleteModel": "Supprimer {{modelName}}"
},
"settings": {
"general": {
"title": "Général",
"shortcut": {
"title": "Raccourcis Handy",
"description": "Configurer les raccourcis clavier pour déclencher l'enregistrement de la reconnaissance vocale",
"loading": "Chargement des raccourcis...",
"none": "Aucun raccourci configuré",
"notFound": "Raccourci non trouvé",
"pressKeys": "Appuyez sur les touches...",
"bindings": {
"transcribe": {
"name": "Transcrire",
"description": "Convertit votre voix en texte."
},
"cancel": {
"name": "Annuler",
"description": "Annule l'enregistrement en cours."
}
},
"errors": {
"restore": "Échec de la restauration du raccourci original",
"set": "Échec de la définition du raccourci : {{error}}",
"reset": "Échec de la réinitialisation du raccourci à sa valeur d'origine"
}
},
"language": {
"title": "Langue",
"description": "Sélectionnez la langue pour la reconnaissance vocale. Auto déterminera automatiquement la langue, tandis que sélectionner une langue spécifique peut améliorer la précision pour cette langue.",
"descriptionUnsupported": "Le modèle Parakeet détecte automatiquement la langue. Aucune sélection manuelle n'est nécessaire.",
"searchPlaceholder": "Rechercher des langues...",
"noResults": "Aucune langue trouvée",
"auto": "Auto"
},
"pushToTalk": {
"label": "Appuyer pour parler",
"description": "Maintenez pour enregistrer, relâchez pour arrêter"
}
},
"sound": {
"title": "Son",
"microphone": {
"title": "Microphone",
"description": "Sélectionnez votre microphone préféré",
"placeholder": "Sélectionner un microphone...",
"loading": "Chargement..."
},
"audioFeedback": {
"label": "Retour audio",
"description": "Jouer un son au début et à la fin de l'enregistrement"
},
"outputDevice": {
"title": "Périphérique de sortie",
"description": "Sélectionnez votre périphérique de sortie audio préféré pour les sons de retour",
"placeholder": "Sélectionner un périphérique de sortie...",
"loading": "Chargement..."
},
"volume": {
"title": "Volume",
"description": "Ajuster le volume des sons de retour audio"
}
},
"advanced": {
"title": "Avancé",
"startHidden": {
"label": "Démarrer masqué",
"description": "Lancer dans la barre système sans ouvrir la fenêtre."
},
"autostart": {
"label": "Lancer au démarrage",
"description": "Démarrer automatiquement Handy lorsque vous vous connectez à votre ordinateur."
},
"overlay": {
"title": "Position de la superposition",
"description": "Afficher une superposition de retour visuel pendant l'enregistrement et la transcription. Sur Linux, 'Aucune' est recommandé.",
"options": {
"none": "Aucune",
"bottom": "Bas",
"top": "Haut"
}
},
"pasteMethod": {
"title": "Méthode de collage",
"description": "Choisissez comment le texte est inséré. Direct : simule la frappe via l'entrée système. Aucun : ignore le collage, met uniquement à jour l'historique/presse-papiers.",
"options": {
"clipboard": "Presse-papiers ({{modifier}}+V)",
"clipboardCtrlShiftV": "Presse-papiers (Ctrl+Shift+V)",
"clipboardShiftInsert": "Presse-papiers (Shift+Insert)",
"direct": "Direct",
"none": "Aucun"
}
},
"clipboardHandling": {
"title": "Gestion du presse-papiers",
"description": "Ne pas modifier le presse-papiers préserve le contenu actuel de votre presse-papiers après la transcription. Copier dans le presse-papiers laisse le résultat de la transcription dans votre presse-papiers après le collage.",
"options": {
"dontModify": "Ne pas modifier le presse-papiers",
"copyToClipboard": "Copier dans le presse-papiers"
}
},
"translateToEnglish": {
"label": "Traduire en anglais",
"description": "Traduire automatiquement la parole d'autres langues vers l'anglais pendant la transcription.",
"descriptionUnsupported": "La traduction n'est pas prise en charge par le modèle {{model}}."
},
"modelUnload": {
"title": "Décharger le modèle",
"description": "Libérer automatiquement la mémoire GPU/CPU lorsque le modèle n'a pas été utilisé pendant le temps spécifié",
"options": {
"never": "Jamais",
"immediately": "Immédiatement",
"min2": "Après 2 minutes",
"min5": "Après 5 minutes",
"min10": "Après 10 minutes",
"min15": "Après 15 minutes",
"hour1": "Après 1 heure",
"sec5": "Après 5 secondes (Débogage)"
}
},
"customWords": {
"title": "Mots personnalisés",
"description": "Ajoutez des mots souvent mal entendus ou mal orthographiés lors de la transcription. Le système corrigera automatiquement les mots similaires pour correspondre à votre liste.",
"placeholder": "Ajouter un mot",
"add": "Ajouter",
"remove": "Supprimer {{word}}"
}
},
"postProcessing": {
"title": "Post-traitement",
"disabledNotice": "Le post-traitement est actuellement désactivé. Activez-le dans les paramètres de débogage pour le configurer.",
"api": {
"title": "API (Compatible OpenAI)",
"provider": {
"title": "Fournisseur",
"description": "Sélectionnez un fournisseur compatible OpenAI."
},
"appleIntelligence": {
"title": "Apple Intelligence",
"description": "Fonctionne entièrement sur l'appareil. Aucune clé API ni accès réseau n'est requis.",
"requirements": "Nécessite un Mac Apple Silicon exécutant macOS Tahoe (26.0) ou une version ultérieure. Apple Intelligence doit être activé dans les Préférences Système."
},
"baseUrl": {
"title": "URL de base",
"description": "URL de base de l'API pour le fournisseur sélectionné. Seul le fournisseur personnalisé peut être modifié.",
"placeholder": "https://api.openai.com/v1"
},
"apiKey": {
"title": "Clé API",
"description": "Clé API pour le fournisseur sélectionné.",
"placeholder": "sk-..."
},
"model": {
"title": "Modèle",
"descriptionApple": "Fournissez une limite de tokens optionnelle ou conservez le préréglage par défaut sur l'appareil.",
"descriptionCustom": "Fournissez l'identifiant du modèle attendu par votre point de terminaison personnalisé.",
"descriptionDefault": "Choisissez un modèle exposé par le fournisseur sélectionné.",
"placeholderApple": "Apple Intelligence",
"placeholderWithOptions": "Rechercher ou sélectionner un modèle",
"placeholderNoOptions": "Tapez un nom de modèle",
"refreshModels": "Actualiser les modèles"
}
},
"prompts": {
"title": "Prompt",
"selectedPrompt": {
"title": "Prompt sélectionné",
"description": "Sélectionnez un modèle pour affiner les transcriptions ou créez-en un nouveau. Utilisez ${output} dans le texte du prompt pour référencer la transcription capturée."
},
"noPrompts": "Aucun prompt disponible",
"selectPrompt": "Sélectionner un prompt",
"createNew": "Créer un nouveau prompt",
"promptLabel": "Libellé du prompt",
"promptLabelPlaceholder": "Entrez le nom du prompt",
"promptInstructions": "Instructions du prompt",
"promptInstructionsPlaceholder": "Écrivez les instructions à exécuter après la transcription. Exemple : Améliorer la grammaire et la clarté du texte suivant : ${output}",
"promptTip": "Astuce : Utilisez <code>${output}</code> pour insérer le texte transcrit dans votre prompt.",
"updatePrompt": "Mettre à jour le prompt",
"deletePrompt": "Supprimer le prompt",
"createPrompt": "Créer le prompt",
"cancel": "Annuler",
"selectToEdit": "Sélectionnez un prompt ci-dessus pour voir et modifier ses détails.",
"createFirst": "Cliquez sur 'Créer un nouveau prompt' ci-dessus pour créer votre premier prompt de post-traitement."
}
},
"history": {
"title": "Historique",
"openFolder": "Ouvrir le dossier des enregistrements",
"loading": "Chargement de l'historique...",
"empty": "Pas encore de transcriptions. Commencez à enregistrer pour construire votre historique !",
"copyToClipboard": "Copier la transcription dans le presse-papiers",
"save": "Enregistrer la transcription",
"unsave": "Retirer des favoris",
"delete": "Supprimer l'entrée",
"deleteError": "Échec de la suppression de l'entrée. Veuillez réessayer."
},
"debug": {
"title": "Débogage",
"logDirectory": {
"title": "Répertoire des journaux",
"description": "Emplacement où les fichiers journaux sont stockés"
},
"logLevel": {
"title": "Niveau de journalisation",
"description": "Définir le niveau de détail de la journalisation"
},
"updateChecks": {
"label": "Vérifier les mises à jour",
"description": "Vérifier automatiquement les nouvelles versions de Handy"
},
"soundTheme": {
"label": "Thème sonore",
"description": "Choisir un thème sonore pour les retours de début et de fin d'enregistrement"
},
"wordCorrectionThreshold": {
"title": "Seuil de correction des mots",
"description": "Sensibilité pour les corrections de mots personnalisés"
},
"historyLimit": {
"title": "Limite d'historique",
"description": "Nombre maximum d'entrées d'historique à conserver"
},
"recordingRetention": {
"title": "Conservation des enregistrements",
"description": "Durée de conservation des enregistrements audio"
},
"alwaysOnMicrophone": {
"label": "Microphone toujours actif",
"description": "Garder le microphone actif pour une réponse plus rapide"
},
"clamshellMicrophone": {
"title": "Microphone en mode fermé",
"description": "Microphone à utiliser lorsque le couvercle du portable est fermé"
},
"postProcessingToggle": {
"label": "Post-traitement",
"description": "Activer l'affinage du texte par IA après la transcription"
},
"muteWhileRecording": {
"label": "Muet pendant l'enregistrement",
"description": "Couper le son du système pendant l'enregistrement"
},
"appendTrailingSpace": {
"label": "Ajouter un espace final",
"description": "Ajouter un espace après la transcription collée"
},
"paths": {
"appData": "Données de l'application :",
"models": "Modèles :",
"settings": "Paramètres :"
}
},
"about": {
"title": "À propos",
"version": {
"title": "Version",
"description": "Version actuelle de Handy"
},
"appDataDirectory": {
"title": "Répertoire des données de l'application",
"description": "Emplacement où Handy stocke ses données"
},
"sourceCode": {
"title": "Code source",
"description": "Voir le code source et contribuer",
"button": "Voir sur GitHub"
},
"supportDevelopment": {
"title": "Soutenir le développement",
"description": "Aidez-nous à continuer à construire Handy",
"button": "Faire un don"
},
"acknowledgments": {
"title": "Remerciements",
"whisper": {
"title": "Whisper.cpp",
"description": "Inférence haute performance du modèle de reconnaissance vocale automatique Whisper d'OpenAI",
"details": "Handy utilise Whisper.cpp pour un traitement rapide et local de la parole en texte. Merci au travail incroyable de Georgi Gerganov et des contributeurs."
}
}
}
},
"footer": {
"downloadingModel": "Téléchargement de {{model}}...",
"checkingUpdates": "Recherche de mises à jour...",
"updateAvailable": "Mise à jour disponible : {{version}}",
"updateAvailableShort": "Mise à jour disponible",
"upToDate": "À jour",
"downloadUpdate": "Télécharger la mise à jour",
"restart": "Redémarrer",
"updateCheckingDisabled": "Vérification des mises à jour désactivée",
"downloading": "Téléchargement... {{progress}}%",
"installing": "Installation...",
"preparing": "Préparation...",
"checkForUpdates": "Rechercher des mises à jour"
},
"common": {
"loading": "Chargement...",
"save": "Enregistrer",
"cancel": "Annuler",
"reset": "Réinitialiser",
"add": "Ajouter",
"remove": "Supprimer",
"delete": "Supprimer",
"edit": "Modifier",
"create": "Créer",
"update": "Mettre à jour",
"close": "Fermer",
"open": "Ouvrir",
"default": "Par défaut",
"enabled": "Activé",
"disabled": "Désactivé",
"on": "Activé",
"off": "Désactivé",
"yes": "Oui",
"no": "Non",
"noOptionsFound": "Aucune option trouvée"
},
"accessibility": {
"permissionsRequired": "Autorisations d'accessibilité requises",
"permissionsDescription": "Handy a besoin des autorisations d'accessibilité pour taper le texte transcrit.",
"openSettings": "Ouvrir les Préférences Système",
"dismiss": "Ignorer"
},
"errors": {
"loadDirectory": "Erreur lors du chargement du répertoire : {{error}}"
},
"appLanguage": {
"title": "Langue de l'application",
"description": "Changer la langue de l'interface de Handy"
}
}

View file

@ -0,0 +1,404 @@
{
"_comment": "Vietnamese translation for Handy. Contribute at: https://github.com/cjpais/Handy",
"sidebar": {
"general": "Chung",
"advanced": "Nâng cao",
"postProcessing": "Xử lý sau",
"history": "Lịch sử",
"debug": "Gỡ lỗi",
"about": "Giới thiệu"
},
"onboarding": {
"subtitle": "Để bắt đầu, hãy chọn một mô hình chuyển đổi giọng nói",
"recommended": "Đề xuất",
"download": "Tải xuống",
"downloading": "Đang tải xuống...",
"modelCard": {
"accuracy": "độ chính xác",
"speed": "tốc độ"
},
"models": {
"small": {
"name": "Whisper Small",
"description": "Nhanh và khá chính xác."
},
"medium": {
"name": "Whisper Medium",
"description": "Độ chính xác tốt, tốc độ trung bình"
},
"turbo": {
"name": "Whisper Turbo",
"description": "Cân bằng giữa độ chính xác và tốc độ."
},
"large": {
"name": "Whisper Large",
"description": "Độ chính xác tốt, nhưng chậm."
},
"parakeet-tdt-0.6b-v2": {
"name": "Parakeet V2",
"description": "Chỉ tiếng Anh. Mô hình tốt nhất cho người nói tiếng Anh."
},
"parakeet-tdt-0.6b-v3": {
"name": "Parakeet V3",
"description": "Nhanh và chính xác"
}
},
"errors": {
"loadModels": "Không thể tải các mô hình có sẵn",
"downloadModel": "Không thể tải mô hình: {{error}}"
}
},
"modelSelector": {
"welcome": "Chào mừng đến với Handy!",
"downloadPrompt": "Tải xuống mô hình bên dưới để bắt đầu chuyển đổi.",
"availableModels": "Các Mô Hình Có Sẵn",
"downloadModels": "Tải Xuống Mô Hình",
"chooseModel": "Chọn Mô Hình",
"active": "Đang hoạt động",
"download": "Tải xuống",
"downloadSize": "Kích thước tải xuống",
"noModelsAvailable": "Không có mô hình nào",
"extracting": "Đang giải nén {{modelName}}...",
"extractingMultiple": "Đang giải nén {{count}} mô hình...",
"extractingGeneric": "Đang giải nén...",
"downloading": "Đang tải xuống {{percentage}}%",
"downloadingMultiple": "Đang tải xuống {{count}} mô hình...",
"modelReady": "Mô Hình Sẵn Sàng",
"loading": "Đang tải {{modelName}}...",
"loadingGeneric": "Đang tải...",
"modelError": "Lỗi Mô Hình",
"modelUnloaded": "Mô Hình Đã Gỡ",
"noModelDownloadRequired": "Chưa Có Mô Hình - Cần Tải Xuống",
"deleteModel": "Xóa {{modelName}}"
},
"settings": {
"general": {
"title": "Chung",
"shortcut": {
"title": "Phím tắt Handy",
"description": "Cấu hình phím tắt để kích hoạt ghi âm chuyển đổi giọng nói thành văn bản",
"loading": "Đang tải phím tắt...",
"none": "Chưa cấu hình phím tắt",
"notFound": "Không tìm thấy phím tắt",
"pressKeys": "Nhấn phím...",
"bindings": {
"transcribe": {
"name": "Chuyển đổi",
"description": "Chuyển đổi giọng nói của bạn thành văn bản."
},
"cancel": {
"name": "Hủy",
"description": "Hủy bản ghi hiện tại."
}
},
"errors": {
"restore": "Không thể khôi phục phím tắt gốc",
"set": "Không thể đặt phím tắt: {{error}}",
"reset": "Không thể đặt lại phím tắt về giá trị gốc"
}
},
"language": {
"title": "Ngôn ngữ",
"description": "Chọn ngôn ngữ để nhận dạng giọng nói. Tự động sẽ tự động xác định ngôn ngữ, trong khi chọn một ngôn ngữ cụ thể có thể cải thiện độ chính xác cho ngôn ngữ đó.",
"descriptionUnsupported": "Mô hình Parakeet tự động phát hiện ngôn ngữ. Không cần chọn thủ công.",
"searchPlaceholder": "Tìm kiếm ngôn ngữ...",
"noResults": "Không tìm thấy ngôn ngữ",
"auto": "Tự động"
},
"pushToTalk": {
"label": "Nhấn để nói",
"description": "Giữ để ghi âm, thả để dừng"
}
},
"sound": {
"title": "Âm thanh",
"microphone": {
"title": "Micrô",
"description": "Chọn thiết bị micrô ưa thích của bạn",
"placeholder": "Chọn micrô...",
"loading": "Đang tải..."
},
"audioFeedback": {
"label": "Phản hồi âm thanh",
"description": "Phát âm thanh khi bắt đầu và kết thúc ghi âm"
},
"outputDevice": {
"title": "Thiết bị đầu ra",
"description": "Chọn thiết bị đầu ra âm thanh ưa thích của bạn cho âm thanh phản hồi",
"placeholder": "Chọn thiết bị đầu ra...",
"loading": "Đang tải..."
},
"volume": {
"title": "Âm lượng",
"description": "Điều chỉnh âm lượng của âm thanh phản hồi"
}
},
"advanced": {
"title": "Nâng cao",
"startHidden": {
"label": "Khởi động ẩn",
"description": "Khởi động vào khay hệ thống mà không mở cửa sổ."
},
"autostart": {
"label": "Khởi động cùng hệ thống",
"description": "Tự động khởi động Handy khi bạn đăng nhập vào máy tính."
},
"overlay": {
"title": "Vị trí lớp phủ",
"description": "Hiển thị lớp phủ phản hồi trực quan trong quá trình ghi âm và chuyển đổi. Trên Linux, 'Không có' được khuyến nghị.",
"options": {
"none": "Không có",
"bottom": "Dưới",
"top": "Trên"
}
},
"pasteMethod": {
"title": "Phương thức dán",
"description": "Chọn cách chèn văn bản. Trực tiếp: mô phỏng gõ phím qua đầu vào hệ thống. Không có: bỏ qua dán, chỉ cập nhật lịch sử/clipboard.",
"options": {
"clipboard": "Clipboard ({{modifier}}+V)",
"clipboardCtrlShiftV": "Clipboard (Ctrl+Shift+V)",
"clipboardShiftInsert": "Clipboard (Shift+Insert)",
"direct": "Trực tiếp",
"none": "Không có"
}
},
"clipboardHandling": {
"title": "Xử lý Clipboard",
"description": "Không sửa đổi Clipboard giữ nguyên nội dung clipboard hiện tại sau khi chuyển đổi. Sao chép vào Clipboard để lại kết quả chuyển đổi trong clipboard sau khi dán.",
"options": {
"dontModify": "Không sửa đổi Clipboard",
"copyToClipboard": "Sao chép vào Clipboard"
}
},
"translateToEnglish": {
"label": "Dịch sang tiếng Anh",
"description": "Tự động dịch giọng nói từ các ngôn ngữ khác sang tiếng Anh trong quá trình chuyển đổi.",
"descriptionUnsupported": "Mô hình {{model}} không hỗ trợ dịch thuật."
},
"modelUnload": {
"title": "Giải phóng mô hình",
"description": "Tự động giải phóng bộ nhớ GPU/CPU khi mô hình không được sử dụng trong thời gian quy định",
"options": {
"never": "Không bao giờ",
"immediately": "Ngay lập tức",
"min2": "Sau 2 phút",
"min5": "Sau 5 phút",
"min10": "Sau 10 phút",
"min15": "Sau 15 phút",
"hour1": "Sau 1 giờ",
"sec5": "Sau 5 giây (Gỡ lỗi)"
}
},
"customWords": {
"title": "Từ tùy chỉnh",
"description": "Thêm các từ thường bị nghe nhầm hoặc viết sai trong quá trình chuyển đổi. Hệ thống sẽ tự động sửa các từ có âm thanh tương tự để khớp với danh sách của bạn.",
"placeholder": "Thêm một từ",
"add": "Thêm",
"remove": "Xóa {{word}}"
}
},
"postProcessing": {
"title": "Xử lý sau",
"disabledNotice": "Xử lý sau hiện đang bị tắt. Bật nó trong cài đặt Gỡ lỗi để cấu hình.",
"api": {
"title": "API (Tương thích OpenAI)",
"provider": {
"title": "Nhà cung cấp",
"description": "Chọn một nhà cung cấp tương thích OpenAI."
},
"appleIntelligence": {
"title": "Apple Intelligence",
"description": "Chạy hoàn toàn trên thiết bị. Không cần khóa API hoặc truy cập mạng.",
"requirements": "Yêu cầu Mac Apple Silicon chạy macOS Tahoe (26.0) trở lên. Apple Intelligence phải được bật trong Cài đặt Hệ thống."
},
"baseUrl": {
"title": "URL cơ sở",
"description": "URL cơ sở API cho nhà cung cấp đã chọn. Chỉ có thể chỉnh sửa nhà cung cấp tùy chỉnh.",
"placeholder": "https://api.openai.com/v1"
},
"apiKey": {
"title": "Khóa API",
"description": "Khóa API cho nhà cung cấp đã chọn.",
"placeholder": "sk-..."
},
"model": {
"title": "Mô hình",
"descriptionApple": "Cung cấp giới hạn token tùy chọn hoặc giữ cài đặt mặc định trên thiết bị.",
"descriptionCustom": "Cung cấp định danh mô hình được yêu cầu bởi điểm cuối tùy chỉnh của bạn.",
"descriptionDefault": "Chọn một mô hình được cung cấp bởi nhà cung cấp đã chọn.",
"placeholderApple": "Apple Intelligence",
"placeholderWithOptions": "Tìm kiếm hoặc chọn một mô hình",
"placeholderNoOptions": "Nhập tên mô hình",
"refreshModels": "Làm mới mô hình"
}
},
"prompts": {
"title": "Prompt",
"selectedPrompt": {
"title": "Prompt đã chọn",
"description": "Chọn một mẫu để tinh chỉnh bản ghi hoặc tạo mới. Sử dụng ${output} trong văn bản prompt để tham chiếu bản ghi đã chụp."
},
"noPrompts": "Không có prompt nào",
"selectPrompt": "Chọn một prompt",
"createNew": "Tạo Prompt mới",
"promptLabel": "Nhãn Prompt",
"promptLabelPlaceholder": "Nhập tên prompt",
"promptInstructions": "Hướng dẫn Prompt",
"promptInstructionsPlaceholder": "Viết hướng dẫn để chạy sau khi chuyển đổi. Ví dụ: Cải thiện ngữ pháp và độ rõ ràng cho văn bản sau: ${output}",
"promptTip": "Mẹo: Sử dụng <code>${output}</code> để chèn văn bản đã chuyển đổi vào prompt của bạn.",
"updatePrompt": "Cập nhật Prompt",
"deletePrompt": "Xóa Prompt",
"createPrompt": "Tạo Prompt",
"cancel": "Hủy",
"selectToEdit": "Chọn một prompt ở trên để xem và chỉnh sửa chi tiết.",
"createFirst": "Nhấn 'Tạo Prompt mới' ở trên để tạo prompt xử lý sau đầu tiên của bạn."
}
},
"history": {
"title": "Lịch sử",
"openFolder": "Mở thư mục ghi âm",
"loading": "Đang tải lịch sử...",
"empty": "Chưa có bản ghi nào. Bắt đầu ghi âm để xây dựng lịch sử của bạn!",
"copyToClipboard": "Sao chép bản ghi vào clipboard",
"save": "Lưu bản ghi",
"unsave": "Xóa khỏi đã lưu",
"delete": "Xóa mục",
"deleteError": "Không thể xóa mục. Vui lòng thử lại."
},
"debug": {
"title": "Gỡ lỗi",
"logDirectory": {
"title": "Thư mục nhật ký",
"description": "Vị trí lưu trữ các tệp nhật ký"
},
"logLevel": {
"title": "Mức nhật ký",
"description": "Đặt mức độ chi tiết của nhật ký"
},
"updateChecks": {
"label": "Kiểm tra cập nhật",
"description": "Tự động kiểm tra phiên bản mới của Handy"
},
"soundTheme": {
"label": "Chủ đề âm thanh",
"description": "Chọn chủ đề âm thanh cho phản hồi bắt đầu và kết thúc ghi âm"
},
"wordCorrectionThreshold": {
"title": "Ngưỡng sửa từ",
"description": "Độ nhạy cho việc sửa từ tùy chỉnh"
},
"historyLimit": {
"title": "Giới hạn lịch sử",
"description": "Số lượng mục lịch sử tối đa cần giữ"
},
"recordingRetention": {
"title": "Lưu giữ ghi âm",
"description": "Thời gian giữ các bản ghi âm"
},
"alwaysOnMicrophone": {
"label": "Micrô luôn bật",
"description": "Giữ micrô hoạt động để phản hồi nhanh hơn"
},
"clamshellMicrophone": {
"title": "Micrô chế độ gập",
"description": "Micrô sử dụng khi nắp laptop được đóng"
},
"postProcessingToggle": {
"label": "Xử lý sau",
"description": "Bật tinh chỉnh văn bản bằng AI sau khi chuyển đổi"
},
"muteWhileRecording": {
"label": "Tắt tiếng khi ghi âm",
"description": "Tắt tiếng âm thanh hệ thống trong khi ghi âm"
},
"appendTrailingSpace": {
"label": "Thêm dấu cách cuối",
"description": "Thêm một dấu cách sau bản ghi đã dán"
},
"paths": {
"appData": "Dữ liệu ứng dụng:",
"models": "Mô hình:",
"settings": "Cài đặt:"
}
},
"about": {
"title": "Giới thiệu",
"version": {
"title": "Phiên bản",
"description": "Phiên bản hiện tại của Handy"
},
"appDataDirectory": {
"title": "Thư mục dữ liệu ứng dụng",
"description": "Vị trí Handy lưu trữ dữ liệu"
},
"sourceCode": {
"title": "Mã nguồn",
"description": "Xem mã nguồn và đóng góp",
"button": "Xem trên GitHub"
},
"supportDevelopment": {
"title": "Hỗ trợ phát triển",
"description": "Giúp chúng tôi tiếp tục xây dựng Handy",
"button": "Quyên góp"
},
"acknowledgments": {
"title": "Lời cảm ơn",
"whisper": {
"title": "Whisper.cpp",
"description": "Suy luận hiệu suất cao của mô hình nhận dạng giọng nói tự động Whisper của OpenAI",
"details": "Handy sử dụng Whisper.cpp để xử lý chuyển đổi giọng nói thành văn bản nhanh, cục bộ. Cảm ơn công việc tuyệt vời của Georgi Gerganov và các cộng tác viên."
}
}
}
},
"footer": {
"downloadingModel": "Đang tải {{model}}...",
"checkingUpdates": "Đang kiểm tra cập nhật...",
"updateAvailable": "Có bản cập nhật: {{version}}",
"updateAvailableShort": "Có bản cập nhật",
"upToDate": "Đã cập nhật",
"downloadUpdate": "Tải cập nhật",
"restart": "Khởi động lại",
"updateCheckingDisabled": "Đã tắt kiểm tra cập nhật",
"downloading": "Đang tải... {{progress}}%",
"installing": "Đang cài đặt...",
"preparing": "Đang chuẩn bị...",
"checkForUpdates": "Kiểm tra cập nhật"
},
"common": {
"loading": "Đang tải...",
"save": "Lưu",
"cancel": "Hủy",
"reset": "Đặt lại",
"add": "Thêm",
"remove": "Xóa",
"delete": "Xóa",
"edit": "Chỉnh sửa",
"create": "Tạo",
"update": "Cập nhật",
"close": "Đóng",
"open": "Mở",
"default": "Mặc định",
"enabled": "Đã bật",
"disabled": "Đã tắt",
"on": "Bật",
"off": "Tắt",
"yes": "Có",
"no": "Không",
"noOptionsFound": "Không tìm thấy tùy chọn"
},
"accessibility": {
"permissionsRequired": "Cần quyền truy cập",
"permissionsDescription": "Handy cần quyền truy cập để gõ văn bản đã chuyển đổi.",
"openSettings": "Mở Cài đặt Hệ thống",
"dismiss": "Bỏ qua"
},
"errors": {
"loadDirectory": "Lỗi khi tải thư mục: {{error}}"
},
"appLanguage": {
"title": "Ngôn ngữ ứng dụng",
"description": "Thay đổi ngôn ngữ giao diện của Handy"
}
}

View file

@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { ModelInfo } from "@/bindings";
/**
* Get the translated name for a model
* @param model - The model info object
* @param t - The translation function from useTranslation
* @returns The translated model name, or the original name if no translation exists
*/
export function getTranslatedModelName(model: ModelInfo, t: TFunction): string {
const translationKey = `onboarding.models.${model.id}.name`;
const translated = t(translationKey, { defaultValue: "" });
return translated !== "" ? translated : model.name;
}
/**
* Get the translated description for a model
* @param model - The model info object
* @param t - The translation function from useTranslation
* @returns The translated model description, or the original description if no translation exists
*/
export function getTranslatedModelDescription(
model: ModelInfo,
t: TFunction,
): string {
const translationKey = `onboarding.models.${model.id}.description`;
const translated = t(translationKey, { defaultValue: "" });
return translated !== "" ? translated : model.description;
}

View file

@ -2,6 +2,9 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
// Initialize i18n
import "./i18n";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />

127
src/utils/dateFormat.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* Format a date string or timestamp to a localized date and time string
* @param timestamp - Unix timestamp in seconds (as string)
* @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')
* @returns Formatted date string
*/
export const formatDateTime = (timestamp: string, locale: string): string => {
try {
// Convert Unix timestamp (seconds) to milliseconds
const timestampMs = parseInt(timestamp, 10) * 1000;
const date = new Date(timestampMs);
// Check if date is valid
if (isNaN(date.getTime())) {
return timestamp; // Return original if invalid
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch (error) {
console.error("Failed to format date:", error);
return timestamp; // Fallback to original timestamp
}
};
/**
* Format a date string or timestamp to a localized date string (no time)
* @param timestamp - Unix timestamp in seconds (as string)
* @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')
* @returns Formatted date string
*/
export const formatDate = (timestamp: string, locale: string): string => {
try {
// Convert Unix timestamp (seconds) to milliseconds
const timestampMs = parseInt(timestamp, 10) * 1000;
const date = new Date(timestampMs);
// Check if date is valid
if (isNaN(date.getTime())) {
return timestamp; // Return original if invalid
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} catch (error) {
console.error("Failed to format date:", error);
return timestamp; // Fallback to original timestamp
}
};
/**
* Format a date string or timestamp to a relative time string (e.g., "2 hours ago")
* @param timestamp - Unix timestamp in seconds (as string)
* @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')
* @returns Relative time string
*/
export const formatRelativeTime = (
timestamp: string,
locale: string,
): string => {
try {
// Convert Unix timestamp (seconds) to milliseconds
const timestampMs = parseInt(timestamp, 10) * 1000;
const date = new Date(timestampMs);
const now = new Date();
// Check if date is valid
if (isNaN(date.getTime())) {
return timestamp; // Return original if invalid
}
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
// Use Intl.RelativeTimeFormat for proper localization
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
// Less than a minute
if (diffInSeconds < 60) {
return rtf.format(-diffInSeconds, "second");
}
// Less than an hour
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return rtf.format(-diffInMinutes, "minute");
}
// Less than a day
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return rtf.format(-diffInHours, "hour");
}
// Less than a week
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return rtf.format(-diffInDays, "day");
}
// Less than a month (30 days)
if (diffInDays < 30) {
const diffInWeeks = Math.floor(diffInDays / 7);
return rtf.format(-diffInWeeks, "week");
}
// Less than a year
if (diffInDays < 365) {
const diffInMonths = Math.floor(diffInDays / 30);
return rtf.format(-diffInMonths, "month");
}
// More than a year
const diffInYears = Math.floor(diffInDays / 365);
return rtf.format(-diffInYears, "year");
} catch (error) {
console.error("Failed to format relative time:", error);
return formatDateTime(timestamp, locale); // Fallback to absolute time
}
};

View file

@ -18,6 +18,7 @@
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/bindings": ["./src/bindings.ts"]
},

View file

@ -12,6 +12,7 @@ export default defineConfig(async () => ({
// Path aliases
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@/bindings": resolve(__dirname, "./src/bindings.ts"),
},
},