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:
parent
700eb71abb
commit
f87051f464
61 changed files with 2945 additions and 296 deletions
18
.github/workflows/lint.yml
vendored
Normal file
18
.github/workflows/lint.yml
vendored
Normal 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
|
||||||
174
CONTRIBUTING_TRANSLATIONS.md
Normal file
174
CONTRIBUTING_TRANSLATIONS.md
Normal 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
213
bun.lock
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "handy-app",
|
"name": "handy-app",
|
||||||
|
|
@ -17,9 +16,11 @@
|
||||||
"@tauri-apps/plugin-sql": "~2.3.1",
|
"@tauri-apps/plugin-sql": "~2.3.1",
|
||||||
"@tauri-apps/plugin-store": "~2.4.1",
|
"@tauri-apps/plugin-store": "~2.4.1",
|
||||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||||
|
"i18next": "^25.7.2",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^16.4.1",
|
||||||
"react-select": "^5.8.0",
|
"react-select": "^5.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
|
|
@ -33,7 +34,11 @@
|
||||||
"@types/react": "^18.3.26",
|
"@types/react": "^18.3.26",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/react-select": "^5.0.1",
|
"@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",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-i18next": "^6.1.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.4.1",
|
"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=="],
|
"@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/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/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=="],
|
"@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/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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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-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=="],
|
"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-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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"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=="],
|
"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=="],
|
"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-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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-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-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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/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=="],
|
"@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/@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=="],
|
"@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
38
eslint.config.js
Normal 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
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"lint:fix": "eslint src --fix",
|
||||||
"format": "prettier --write . && cd src-tauri && cargo fmt",
|
"format": "prettier --write . && cd src-tauri && cargo fmt",
|
||||||
"format:check": "prettier --check . && cd src-tauri && cargo fmt -- --check",
|
"format:check": "prettier --check . && cd src-tauri && cargo fmt -- --check",
|
||||||
"format:frontend": "prettier --write .",
|
"format:frontend": "prettier --write .",
|
||||||
|
|
@ -28,9 +30,11 @@
|
||||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||||
"react-select": "^5.8.0",
|
"react-select": "^5.8.0",
|
||||||
"tauri-plugin-macos-permissions-api": "2.3.0",
|
"tauri-plugin-macos-permissions-api": "2.3.0",
|
||||||
|
"i18next": "^25.7.2",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^16.4.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
|
|
@ -42,7 +46,11 @@
|
||||||
"@types/react": "^18.3.26",
|
"@types/react": "^18.3.26",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/react-select": "^5.0.1",
|
"@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",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-i18next": "^6.1.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
"vite": "^6.4.1"
|
"vite": "^6.4.1"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
checkAccessibilityPermission,
|
checkAccessibilityPermission,
|
||||||
requestAccessibilityPermission,
|
requestAccessibilityPermission,
|
||||||
|
|
@ -14,6 +15,7 @@ interface ButtonConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccessibilityPermissions: React.FC = () => {
|
const AccessibilityPermissions: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);
|
const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);
|
||||||
const [permissionState, setPermissionState] =
|
const [permissionState, setPermissionState] =
|
||||||
useState<PermissionState>("request");
|
useState<PermissionState>("request");
|
||||||
|
|
@ -61,12 +63,12 @@ const AccessibilityPermissions: React.FC = () => {
|
||||||
// Configure button text and style based on state
|
// Configure button text and style based on state
|
||||||
const buttonConfig: Record<PermissionState, ButtonConfig | null> = {
|
const buttonConfig: Record<PermissionState, ButtonConfig | null> = {
|
||||||
request: {
|
request: {
|
||||||
text: "Grant",
|
text: t("accessibility.openSettings"),
|
||||||
className:
|
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",
|
"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: {
|
verify: {
|
||||||
text: "Verify",
|
text: t("accessibility.openSettings"),
|
||||||
className:
|
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",
|
"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="flex justify-between items-center gap-2">
|
||||||
<div className="">
|
<div className="">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
Please grant accessibility permissions for Handy
|
{t("accessibility.permissionsDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react";
|
import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react";
|
||||||
import HandyTextLogo from "./icons/HandyTextLogo";
|
import HandyTextLogo from "./icons/HandyTextLogo";
|
||||||
import HandyHand from "./icons/HandyHand";
|
import HandyHand from "./icons/HandyHand";
|
||||||
|
|
@ -23,7 +24,7 @@ interface IconProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionConfig {
|
interface SectionConfig {
|
||||||
label: string;
|
labelKey: string;
|
||||||
icon: React.ComponentType<IconProps>;
|
icon: React.ComponentType<IconProps>;
|
||||||
component: React.ComponentType;
|
component: React.ComponentType;
|
||||||
enabled: (settings: any) => boolean;
|
enabled: (settings: any) => boolean;
|
||||||
|
|
@ -31,37 +32,37 @@ interface SectionConfig {
|
||||||
|
|
||||||
export const SECTIONS_CONFIG = {
|
export const SECTIONS_CONFIG = {
|
||||||
general: {
|
general: {
|
||||||
label: "General",
|
labelKey: "sidebar.general",
|
||||||
icon: HandyHand,
|
icon: HandyHand,
|
||||||
component: GeneralSettings,
|
component: GeneralSettings,
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
label: "Advanced",
|
labelKey: "sidebar.advanced",
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
component: AdvancedSettings,
|
component: AdvancedSettings,
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
},
|
},
|
||||||
postprocessing: {
|
postprocessing: {
|
||||||
label: "Post Process",
|
labelKey: "sidebar.postProcessing",
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
component: PostProcessingSettings,
|
component: PostProcessingSettings,
|
||||||
enabled: (settings) => settings?.post_process_enabled ?? false,
|
enabled: (settings) => settings?.post_process_enabled ?? false,
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
label: "History",
|
labelKey: "sidebar.history",
|
||||||
icon: History,
|
icon: History,
|
||||||
component: HistorySettings,
|
component: HistorySettings,
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
},
|
},
|
||||||
debug: {
|
debug: {
|
||||||
label: "Debug",
|
labelKey: "sidebar.debug",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
component: DebugSettings,
|
component: DebugSettings,
|
||||||
enabled: (settings) => settings?.debug_mode ?? false,
|
enabled: (settings) => settings?.debug_mode ?? false,
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
label: "About",
|
labelKey: "sidebar.about",
|
||||||
icon: Info,
|
icon: Info,
|
||||||
component: AboutSettings,
|
component: AboutSettings,
|
||||||
enabled: () => true,
|
enabled: () => true,
|
||||||
|
|
@ -77,6 +78,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
activeSection,
|
activeSection,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const availableSections = Object.entries(SECTIONS_CONFIG)
|
const availableSections = Object.entries(SECTIONS_CONFIG)
|
||||||
|
|
@ -102,7 +104,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
onClick={() => onSectionChange(section.id)}
|
onClick={() => onSectionChange(section.id)}
|
||||||
>
|
>
|
||||||
<Icon width={24} height={24} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const Footer: React.FC = () => {
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<UpdateChecker />
|
<UpdateChecker />
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||||
<span>v{version}</span>
|
<span>v{version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ModelInfo } from "@/bindings";
|
import type { ModelInfo } from "@/bindings";
|
||||||
import { formatModelSize } from "../../lib/utils/format";
|
import { formatModelSize } from "../../lib/utils/format";
|
||||||
|
import {
|
||||||
|
getTranslatedModelName,
|
||||||
|
getTranslatedModelDescription,
|
||||||
|
} from "../../lib/utils/modelTranslation";
|
||||||
import { ProgressBar } from "../shared";
|
import { ProgressBar } from "../shared";
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
|
|
@ -29,6 +34,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
onModelDelete,
|
onModelDelete,
|
||||||
onError,
|
onError,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const availableModels = models.filter((m) => m.is_downloaded);
|
const availableModels = models.filter((m) => m.is_downloaded);
|
||||||
const downloadableModels = models.filter((m) => !m.is_downloaded);
|
const downloadableModels = models.filter((m) => !m.is_downloaded);
|
||||||
const isFirstRun = availableModels.length === 0 && models.length > 0;
|
const isFirstRun = availableModels.length === 0 && models.length > 0;
|
||||||
|
|
@ -65,10 +71,10 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
{isFirstRun && (
|
{isFirstRun && (
|
||||||
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
|
<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">
|
<div className="text-xs font-medium text-logo-primary mb-1">
|
||||||
Welcome to Handy!
|
{t("modelSelector.welcome")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text/70">
|
<div className="text-xs text-text/70">
|
||||||
Download a model below to get started with transcription.
|
{t("modelSelector.downloadPrompt")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -77,7 +83,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
{availableModels.length > 0 && (
|
{availableModels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
|
<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>
|
</div>
|
||||||
{availableModels.map((model) => (
|
{availableModels.map((model) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -99,20 +105,26 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="text-xs text-text/40 italic pr-4">
|
||||||
{model.description}
|
{getTranslatedModelDescription(model, t)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{currentModelId === model.id && (
|
{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 && (
|
{currentModelId !== model.id && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteClick(e, model.id)}
|
onClick={(e) => handleDeleteClick(e, model.id)}
|
||||||
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
|
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
|
<svg
|
||||||
className="w-3 h-3"
|
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="border-t border-mid-gray/10 my-1" />
|
||||||
)}
|
)}
|
||||||
<div className="px-3 py-1 text-xs font-medium text-text/80">
|
<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>
|
</div>
|
||||||
{downloadableModels.map((model) => {
|
{downloadableModels.map((model) => {
|
||||||
const isDownloading = downloadProgress.has(model.id);
|
const isDownloading = downloadProgress.has(model.id);
|
||||||
|
|
@ -169,24 +183,25 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{model.name}
|
{getTranslatedModelName(model, t)}
|
||||||
{model.id === "small" && isFirstRun && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text/40 italic pr-4">
|
<div className="text-xs text-text/40 italic pr-4">
|
||||||
{model.description}
|
{getTranslatedModelDescription(model, t)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-text/50 tabular-nums">
|
<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>
|
</div>
|
||||||
<div className="text-xs text-logo-primary tabular-nums">
|
<div className="text-xs text-logo-primary tabular-nums">
|
||||||
{isDownloading && progress
|
{isDownloading && progress
|
||||||
? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%`
|
? `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%`
|
||||||
: "Download"}
|
: t("modelSelector.download")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -213,7 +228,7 @@ const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||||
{/* No Models Available */}
|
{/* No Models Available */}
|
||||||
{availableModels.length === 0 && downloadableModels.length === 0 && (
|
{availableModels.length === 0 && downloadableModels.length === 0 && (
|
||||||
<div className="px-3 py-2 text-sm text-text/60">
|
<div className="px-3 py-2 text-sm text-text/60">
|
||||||
No models available
|
{t("modelSelector.noModelsAvailable")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { commands, type ModelInfo } from "@/bindings";
|
import { commands, type ModelInfo } from "@/bindings";
|
||||||
|
import { getTranslatedModelName } from "../../lib/utils/modelTranslation";
|
||||||
import ModelStatusButton from "./ModelStatusButton";
|
import ModelStatusButton from "./ModelStatusButton";
|
||||||
import ModelDropdown from "./ModelDropdown";
|
import ModelDropdown from "./ModelDropdown";
|
||||||
import DownloadProgressDisplay from "./DownloadProgressDisplay";
|
import DownloadProgressDisplay from "./DownloadProgressDisplay";
|
||||||
|
|
@ -40,6 +42,7 @@ interface ModelSelectorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [currentModelId, setCurrentModelId] = useState<string>("");
|
const [currentModelId, setCurrentModelId] = useState<string>("");
|
||||||
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
|
const [modelStatus, setModelStatus] = useState<ModelStatus>("unloaded");
|
||||||
|
|
@ -322,9 +325,14 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
if (extractingModels.size === 1) {
|
if (extractingModels.size === 1) {
|
||||||
const [modelId] = Array.from(extractingModels);
|
const [modelId] = Array.from(extractingModels);
|
||||||
const model = models.find((m) => m.id === modelId);
|
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 {
|
} else {
|
||||||
return `Extracting ${extractingModels.size} models...`;
|
return t("modelSelector.extractingMultiple", {
|
||||||
|
count: extractingModels.size,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,9 +343,11 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||||
0,
|
0,
|
||||||
Math.min(100, Math.round(progress.percentage)),
|
Math.min(100, Math.round(progress.percentage)),
|
||||||
);
|
);
|
||||||
return `Downloading ${percentage}%`;
|
return t("modelSelector.downloading", { percentage });
|
||||||
} else {
|
} 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) {
|
switch (modelStatus) {
|
||||||
case "ready":
|
case "ready":
|
||||||
return currentModel?.name || "Model Ready";
|
return currentModel
|
||||||
|
? getTranslatedModelName(currentModel, t)
|
||||||
|
: t("modelSelector.modelReady");
|
||||||
case "loading":
|
case "loading":
|
||||||
return currentModel ? `Loading ${currentModel.name}...` : "Loading...";
|
return currentModel
|
||||||
|
? t("modelSelector.loading", {
|
||||||
|
modelName: getTranslatedModelName(currentModel, t),
|
||||||
|
})
|
||||||
|
: t("modelSelector.loadingGeneric");
|
||||||
case "extracting":
|
case "extracting":
|
||||||
return currentModel
|
return currentModel
|
||||||
? `Extracting ${currentModel.name}...`
|
? t("modelSelector.extracting", {
|
||||||
: "Extracting...";
|
modelName: getTranslatedModelName(currentModel, t),
|
||||||
|
})
|
||||||
|
: t("modelSelector.extractingGeneric");
|
||||||
case "error":
|
case "error":
|
||||||
return modelError || "Model Error";
|
return modelError || t("modelSelector.modelError");
|
||||||
case "unloaded":
|
case "unloaded":
|
||||||
return currentModel?.name || "Model Unloaded";
|
return currentModel
|
||||||
|
? getTranslatedModelName(currentModel, t)
|
||||||
|
: t("modelSelector.modelUnloaded");
|
||||||
case "none":
|
case "none":
|
||||||
return "No Model - Download Required";
|
return t("modelSelector.noModelDownloadRequired");
|
||||||
default:
|
default:
|
||||||
return currentModel?.name || "Model Unloaded";
|
return currentModel
|
||||||
|
? getTranslatedModelName(currentModel, t)
|
||||||
|
: t("modelSelector.modelUnloaded");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import type { ModelInfo } from "@/bindings";
|
import type { ModelInfo } from "@/bindings";
|
||||||
import { formatModelSize } from "../../lib/utils/format";
|
import { formatModelSize } from "../../lib/utils/format";
|
||||||
|
import {
|
||||||
|
getTranslatedModelName,
|
||||||
|
getTranslatedModelDescription,
|
||||||
|
} from "../../lib/utils/modelTranslation";
|
||||||
import Badge from "../ui/Badge";
|
import Badge from "../ui/Badge";
|
||||||
|
|
||||||
interface ModelCardProps {
|
interface ModelCardProps {
|
||||||
|
|
@ -19,8 +24,13 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||||
className = "",
|
className = "",
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isFeatured = variant === "featured";
|
const isFeatured = variant === "featured";
|
||||||
|
|
||||||
|
// Get translated model name and description
|
||||||
|
const displayName = getTranslatedModelName(model, t);
|
||||||
|
const displayDescription = getTranslatedModelDescription(model, t);
|
||||||
|
|
||||||
const baseButtonClasses =
|
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";
|
"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 flex-col items-ce">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
|
<h3 className="text-lg font-semibold text-text group-hover:text-logo-primary transition-colors">
|
||||||
{model.name}
|
{displayName}
|
||||||
</h3>
|
</h3>
|
||||||
<DownloadSize sizeMb={Number(model.size_mb)} />
|
<DownloadSize sizeMb={Number(model.size_mb)} />
|
||||||
{isFeatured && <Badge variant="primary">Recommended</Badge>}
|
{isFeatured && (
|
||||||
|
<Badge variant="primary">{t("onboarding.recommended")}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text/60 text-sm leading-relaxed">
|
<p className="text-text/60 text-sm leading-relaxed">
|
||||||
{model.description}
|
{displayDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<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="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-logo-primary rounded-full transition-all duration-300"
|
className="h-full bg-logo-primary rounded-full transition-all duration-300"
|
||||||
|
|
@ -61,7 +75,9 @@ const ModelCard: React.FC<ModelCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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="w-20 h-2 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-logo-primary rounded-full transition-all duration-300"
|
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 DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-text/60 tabular-nums">
|
<div className="flex items-center gap-1.5 text-xs text-text/60 tabular-nums">
|
||||||
<Download
|
<Download
|
||||||
|
|
@ -82,7 +100,7 @@ const DownloadSize = ({ sizeMb }: { sizeMb: number }) => {
|
||||||
className="h-3.5 w-3.5 text-text/45"
|
className="h-3.5 w-3.5 text-text/45"
|
||||||
strokeWidth={1.75}
|
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">
|
<span className="font-medium text-text/70">
|
||||||
{formatModelSize(sizeMb)}
|
{formatModelSize(sizeMb)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { commands, type ModelInfo } from "@/bindings";
|
import { commands, type ModelInfo } from "@/bindings";
|
||||||
import ModelCard from "./ModelCard";
|
import ModelCard from "./ModelCard";
|
||||||
import HandyTextLogo from "../icons/HandyTextLogo";
|
import HandyTextLogo from "../icons/HandyTextLogo";
|
||||||
|
|
@ -8,6 +9,7 @@ interface OnboardingProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -23,11 +25,11 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||||
// Only show downloadable models for onboarding
|
// Only show downloadable models for onboarding
|
||||||
setAvailableModels(result.data.filter((m) => !m.is_downloaded));
|
setAvailableModels(result.data.filter((m) => !m.is_downloaded));
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to load available models");
|
setError(t("onboarding.errors.loadModels"));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load models:", 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);
|
const result = await commands.downloadModel(modelId);
|
||||||
if (result.status === "error") {
|
if (result.status === "error") {
|
||||||
console.error("Download failed:", result.error);
|
console.error("Download failed:", result.error);
|
||||||
setError(`Failed to download model: ${result.error}`);
|
setError(t("onboarding.errors.downloadModel", { error: result.error }));
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Download failed:", err);
|
console.error("Download failed:", err);
|
||||||
setError(`Failed to download model: ${err}`);
|
setError(t("onboarding.errors.downloadModel", { error: String(err) }));
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -61,7 +63,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||||
<div className="flex flex-col items-center gap-2 shrink-0">
|
<div className="flex flex-col items-center gap-2 shrink-0">
|
||||||
<HandyTextLogo width={200} />
|
<HandyTextLogo width={200} />
|
||||||
<p className="text-text/70 max-w-md font-medium mx-auto">
|
<p className="text-text/70 max-w-md font-medium mx-auto">
|
||||||
To get started, choose a transcription model
|
{t("onboarding.subtitle")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface AlwaysOnMicrophoneProps {
|
||||||
|
|
||||||
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(
|
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const alwaysOnMode = getSetting("always_on_microphone") || false;
|
const alwaysOnMode = getSetting("always_on_microphone") || false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(
|
||||||
checked={alwaysOnMode}
|
checked={alwaysOnMode}
|
||||||
onChange={(enabled) => updateSetting("always_on_microphone", enabled)}
|
onChange={(enabled) => updateSetting("always_on_microphone", enabled)}
|
||||||
isUpdating={isUpdating("always_on_microphone")}
|
isUpdating={isUpdating("always_on_microphone")}
|
||||||
label="Always-On Microphone"
|
label={t("settings.debug.alwaysOnMicrophone.label")}
|
||||||
description="Keep microphone active for low latency recording. This may prevent your computer from sleeping."
|
description={t("settings.debug.alwaysOnMicrophone.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { commands } from "@/bindings";
|
import { commands } from "@/bindings";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
|
|
@ -12,6 +13,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
|
||||||
descriptionMode = "inline",
|
descriptionMode = "inline",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [appDirPath, setAppDirPath] = useState<string>("");
|
const [appDirPath, setAppDirPath] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -59,7 +61,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
<p className="text-red-600 text-sm">
|
<p className="text-red-600 text-sm">
|
||||||
Error loading app directory: {error}
|
{t("errors.loadDirectory", { error })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -67,8 +69,8 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="App Data Directory"
|
title={t("settings.about.appDataDirectory.title")}
|
||||||
description="Main directory where application data, settings, and models are stored"
|
description={t("settings.about.appDataDirectory.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
|
|
@ -84,7 +86,7 @@ export const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({
|
||||||
disabled={!appDirPath}
|
disabled={!appDirPath}
|
||||||
className="px-3 py-2"
|
className="px-3 py-2"
|
||||||
>
|
>
|
||||||
Open
|
{t("common.open")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
|
||||||
45
src/components/settings/AppLanguageSelector.tsx
Normal file
45
src/components/settings/AppLanguageSelector.tsx
Normal 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";
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface AppendTrailingSpaceProps {
|
||||||
|
|
||||||
export const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =
|
export const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const enabled = getSetting("append_trailing_space") ?? false;
|
const enabled = getSetting("append_trailing_space") ?? false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(enabled) => updateSetting("append_trailing_space", enabled)}
|
onChange={(enabled) => updateSetting("append_trailing_space", enabled)}
|
||||||
isUpdating={isUpdating("append_trailing_space")}
|
isUpdating={isUpdating("append_trailing_space")}
|
||||||
label="Append Trailing Space"
|
label={t("settings.debug.appendTrailingSpace.label")}
|
||||||
description="Automatically add a space at the end of transcribed text, making it easier to dictate multiple sentences in a row."
|
description={t("settings.debug.appendTrailingSpace.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { VolumeSlider } from "./VolumeSlider";
|
import { VolumeSlider } from "./VolumeSlider";
|
||||||
|
|
@ -11,6 +12,7 @@ interface AudioFeedbackProps {
|
||||||
|
|
||||||
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
|
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
|
const audioFeedbackEnabled = getSetting("audio_feedback") || false;
|
||||||
|
|
||||||
|
|
@ -20,8 +22,8 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(
|
||||||
checked={audioFeedbackEnabled}
|
checked={audioFeedbackEnabled}
|
||||||
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
|
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
|
||||||
isUpdating={isUpdating("audio_feedback")}
|
isUpdating={isUpdating("audio_feedback")}
|
||||||
label="Audio Feedback"
|
label={t("settings.sound.audioFeedback.label")}
|
||||||
description="Play sound when recording starts and stops"
|
description={t("settings.sound.audioFeedback.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface AutostartToggleProps {
|
||||||
|
|
||||||
export const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(
|
export const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const autostartEnabled = getSetting("autostart_enabled") ?? false;
|
const autostartEnabled = getSetting("autostart_enabled") ?? false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(
|
||||||
checked={autostartEnabled}
|
checked={autostartEnabled}
|
||||||
onChange={(enabled) => updateSetting("autostart_enabled", enabled)}
|
onChange={(enabled) => updateSetting("autostart_enabled", enabled)}
|
||||||
isUpdating={isUpdating("autostart_enabled")}
|
isUpdating={isUpdating("autostart_enabled")}
|
||||||
label="Launch on Startup"
|
label={t("settings.advanced.autostart.label")}
|
||||||
description="Automatically start Handy when you log in to your computer."
|
description={t("settings.advanced.autostart.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { commands } from "@/bindings";
|
import { commands } from "@/bindings";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
|
|
@ -12,6 +13,7 @@ interface ClamshellMicrophoneSelectorProps {
|
||||||
|
|
||||||
export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorProps> =
|
export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
getSetting,
|
getSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
|
|
@ -67,8 +69,8 @@ export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorPr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Clamshell Microphone"
|
title={t("settings.debug.clamshellMicrophone.title")}
|
||||||
description="Choose a different microphone to use when your laptop lid is closed"
|
description={t("settings.debug.clamshellMicrophone.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
@ -79,8 +81,8 @@ export const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorPr
|
||||||
onSelect={handleClamshellMicrophoneSelect}
|
onSelect={handleClamshellMicrophoneSelect}
|
||||||
placeholder={
|
placeholder={
|
||||||
isLoading || audioDevices.length === 0
|
isLoading || audioDevices.length === 0
|
||||||
? "Loading..."
|
? t("common.loading")
|
||||||
: "Select microphone..."
|
: t("settings.sound.microphone.placeholder")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdating("clamshell_microphone") ||
|
isUpdating("clamshell_microphone") ||
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
@ -9,22 +10,29 @@ interface ClipboardHandlingProps {
|
||||||
grouped?: boolean;
|
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> =
|
export const ClipboardHandlingSetting: React.FC<ClipboardHandlingProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
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") ||
|
const selectedHandling = (getSetting("clipboard_handling") ||
|
||||||
"dont_modify") as ClipboardHandling;
|
"dont_modify") as ClipboardHandling;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Clipboard Handling"
|
title={t("settings.advanced.clipboardHandling.title")}
|
||||||
description="Don't Modify Clipboard preserves your current clipboard contents after transcription. Copy to Clipboard leaves the transcription result in your clipboard after pasting."
|
description={t("settings.advanced.clipboardHandling.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
|
|
@ -11,6 +12,7 @@ interface CustomWordsProps {
|
||||||
|
|
||||||
export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const [newWord, setNewWord] = useState("");
|
const [newWord, setNewWord] = useState("");
|
||||||
const customWords = getSetting("custom_words") || [];
|
const customWords = getSetting("custom_words") || [];
|
||||||
|
|
@ -46,8 +48,8 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Custom Words"
|
title={t("settings.advanced.customWords.title")}
|
||||||
description="Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list."
|
description={t("settings.advanced.customWords.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
@ -58,7 +60,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
value={newWord}
|
value={newWord}
|
||||||
onChange={(e) => setNewWord(e.target.value)}
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
onKeyDown={handleKeyPress}
|
onKeyDown={handleKeyPress}
|
||||||
placeholder="Add a word"
|
placeholder={t("settings.advanced.customWords.placeholder")}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
disabled={isUpdating("custom_words")}
|
disabled={isUpdating("custom_words")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -73,7 +75,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
Add
|
{t("settings.advanced.customWords.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
@ -89,7 +91,7 @@ export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="inline-flex items-center gap-1 cursor-pointer"
|
className="inline-flex items-center gap-1 cursor-pointer"
|
||||||
aria-label={`Remove ${word}`}
|
aria-label={t("settings.advanced.customWords.remove", { word })}
|
||||||
>
|
>
|
||||||
<span>{word}</span>
|
<span>{word}</span>
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import {
|
import {
|
||||||
getKeyName,
|
getKeyName,
|
||||||
|
|
@ -25,6 +26,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
shortcutId,
|
shortcutId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
|
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
|
||||||
useSettings();
|
useSettings();
|
||||||
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
const [keyPressed, setKeyPressed] = useState<string[]>([]);
|
||||||
|
|
@ -89,7 +91,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to restore original binding:", 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) {
|
} else if (editingShortcutId) {
|
||||||
await commands.resumeBinding(editingShortcutId).catch(console.error);
|
await commands.resumeBinding(editingShortcutId).catch(console.error);
|
||||||
|
|
@ -141,7 +143,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to change binding:", 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
|
// Reset to original binding on error
|
||||||
if (originalBinding) {
|
if (originalBinding) {
|
||||||
|
|
@ -152,7 +158,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
console.error("Failed to reset binding:", 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(console.error);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to restore original binding:", 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) {
|
} else if (editingShortcutId) {
|
||||||
commands.resumeBinding(editingShortcutId).catch(console.error);
|
commands.resumeBinding(editingShortcutId).catch(console.error);
|
||||||
|
|
@ -228,7 +234,8 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
|
|
||||||
// Format the current shortcut keys being recorded
|
// Format the current shortcut keys being recorded
|
||||||
const formatCurrentKeys = (): string => {
|
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
|
// Use the same formatting as the display to ensure consistency
|
||||||
return formatKeyCombination(recordedKeys.join("+"), osType);
|
return formatKeyCombination(recordedKeys.join("+"), osType);
|
||||||
|
|
@ -243,12 +250,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Handy Shortcuts"
|
title={t("settings.general.shortcut.title")}
|
||||||
description="Configure keyboard shortcuts to trigger speech-to-text recording"
|
description={t("settings.general.shortcut.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
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>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -257,12 +266,14 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
if (Object.keys(bindings).length === 0) {
|
if (Object.keys(bindings).length === 0) {
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Handy Shortcuts"
|
title={t("settings.general.shortcut.title")}
|
||||||
description="Configure keyboard shortcuts to trigger speech-to-text recording"
|
description={t("settings.general.shortcut.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
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>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -271,20 +282,32 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Shortcut"
|
title={t("settings.general.shortcut.title")}
|
||||||
description="Shortcut not found"
|
description={t("settings.general.shortcut.notFound")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
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>
|
</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 (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title={binding.name}
|
title={translatedName}
|
||||||
description={binding.description}
|
description={translatedDescription}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
|
|
@ -12,6 +13,7 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
|
||||||
descriptionMode = "inline",
|
descriptionMode = "inline",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const historyLimit = Number(getSetting("history_limit") ?? "5");
|
const historyLimit = Number(getSetting("history_limit") ?? "5");
|
||||||
|
|
@ -25,8 +27,8 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="History Limit"
|
title={t("settings.debug.historyLimit.title")}
|
||||||
description="Maximum number of transcription entries to keep in history"
|
description={t("settings.debug.historyLimit.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
|
|
@ -41,7 +43,9 @@ export const HistoryLimit: React.FC<HistoryLimitProps> = ({
|
||||||
disabled={isUpdating("history_limit")}
|
disabled={isUpdating("history_limit")}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-text">entries</span>
|
<span className="text-sm text-text">
|
||||||
|
{t("settings.debug.historyLimit.entries")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { ResetButton } from "../ui/ResetButton";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
|
|
@ -17,6 +18,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();
|
||||||
const { currentModel, loadCurrentModel } = useModels();
|
const { currentModel, loadCurrentModel } = useModels();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
@ -70,9 +72,9 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedLanguageName = isUnsupported
|
const selectedLanguageName = isUnsupported
|
||||||
? "Auto"
|
? t("settings.general.language.auto")
|
||||||
: LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label ||
|
: LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label ||
|
||||||
"Auto";
|
t("settings.general.language.auto");
|
||||||
|
|
||||||
const handleLanguageSelect = async (languageCode: string) => {
|
const handleLanguageSelect = async (languageCode: string) => {
|
||||||
await updateSetting("selected_language", languageCode);
|
await updateSetting("selected_language", languageCode);
|
||||||
|
|
@ -105,11 +107,11 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Language"
|
title={t("settings.general.language.title")}
|
||||||
description={
|
description={
|
||||||
isUnsupported
|
isUnsupported
|
||||||
? "Parakeet model automatically detects the language. No manual selection is needed."
|
? t("settings.general.language.descriptionUnsupported")
|
||||||
: "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.description")
|
||||||
}
|
}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
|
@ -155,7 +157,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
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>
|
</div>
|
||||||
|
|
@ -163,7 +165,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{filteredLanguages.length === 0 ? (
|
{filteredLanguages.length === 0 ? (
|
||||||
<div className="px-2 py-2 text-sm text-mid-gray text-center">
|
<div className="px-2 py-2 text-sm text-mid-gray text-center">
|
||||||
No languages found
|
{t("settings.general.language.noResults")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredLanguages.map((language) => (
|
filteredLanguages.map((language) => (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { ResetButton } from "../ui/ResetButton";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
|
|
@ -11,6 +12,7 @@ interface MicrophoneSelectorProps {
|
||||||
|
|
||||||
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
|
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
getSetting,
|
getSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
|
|
@ -41,8 +43,8 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Microphone"
|
title={t("settings.sound.microphone.title")}
|
||||||
description="Select your preferred microphone device"
|
description={t("settings.sound.microphone.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
@ -53,8 +55,8 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(
|
||||||
onSelect={handleMicrophoneSelect}
|
onSelect={handleMicrophoneSelect}
|
||||||
placeholder={
|
placeholder={
|
||||||
isLoading || audioDevices.length === 0
|
isLoading || audioDevices.length === 0
|
||||||
? "Loading..."
|
? t("settings.sound.microphone.loading")
|
||||||
: "Select microphone..."
|
: t("settings.sound.microphone.placeholder")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdating("selected_microphone") ||
|
isUpdating("selected_microphone") ||
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { commands, type ModelUnloadTimeout } from "@/bindings";
|
import { commands, type ModelUnloadTimeout } from "@/bindings";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
|
|
@ -9,27 +10,52 @@ interface ModelUnloadTimeoutProps {
|
||||||
grouped?: boolean;
|
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> = ({
|
export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
|
||||||
descriptionMode = "inline",
|
descriptionMode = "inline",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { settings, getSetting, updateSetting } = useSettings();
|
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 handleChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newTimeout = event.target.value as ModelUnloadTimeout;
|
const newTimeout = event.target.value as ModelUnloadTimeout;
|
||||||
|
|
||||||
|
|
@ -49,8 +75,8 @@ export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Unload Model"
|
title={t("settings.advanced.modelUnload.title")}
|
||||||
description="Automatically free GPU/CPU memory when the model hasn't been used for the specified time"
|
description={t("settings.advanced.modelUnload.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface MuteWhileRecordingToggleProps {
|
||||||
|
|
||||||
export const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =
|
export const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const muteEnabled = getSetting("mute_while_recording") ?? false;
|
const muteEnabled = getSetting("mute_while_recording") ?? false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =
|
||||||
checked={muteEnabled}
|
checked={muteEnabled}
|
||||||
onChange={(enabled) => updateSetting("mute_while_recording", enabled)}
|
onChange={(enabled) => updateSetting("mute_while_recording", enabled)}
|
||||||
isUpdating={isUpdating("mute_while_recording")}
|
isUpdating={isUpdating("mute_while_recording")}
|
||||||
label="Mute While Recording"
|
label={t("settings.debug.muteWhileRecording.label")}
|
||||||
description="Automatically mute all sound output while Handy is recording, then restore it when finished."
|
description={t("settings.debug.muteWhileRecording.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { ResetButton } from "../ui/ResetButton";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
|
|
@ -14,6 +15,7 @@ interface OutputDeviceSelectorProps {
|
||||||
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
|
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
|
||||||
React.memo(
|
React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
|
({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
getSetting,
|
getSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
|
|
@ -44,8 +46,8 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Output Device"
|
title={t("settings.sound.outputDevice.title")}
|
||||||
description="Select your preferred audio output device for feedback sounds"
|
description={t("settings.sound.outputDevice.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
@ -57,8 +59,8 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
|
||||||
onSelect={handleOutputDeviceSelect}
|
onSelect={handleOutputDeviceSelect}
|
||||||
placeholder={
|
placeholder={
|
||||||
isLoading || outputDevices.length === 0
|
isLoading || outputDevices.length === 0
|
||||||
? "Loading..."
|
? t("settings.sound.outputDevice.loading")
|
||||||
: "Select output device..."
|
: t("settings.sound.outputDevice.placeholder")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type as getOsType } from "@tauri-apps/plugin-os";
|
import { type as getOsType } from "@tauri-apps/plugin-os";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
|
|
@ -10,31 +11,53 @@ interface PasteMethodProps {
|
||||||
grouped?: boolean;
|
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(
|
export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const [osType, setOsType] = useState<string>("unknown");
|
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(() => {
|
useEffect(() => {
|
||||||
setOsType(getOsType());
|
setOsType(getOsType());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -46,8 +69,8 @@ export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Paste Method"
|
title={t("settings.advanced.pasteMethod.title")}
|
||||||
description="Choose how text is inserted. Direct: simulates typing via system input. None: skips paste, only updates history/clipboard."
|
description={t("settings.advanced.pasteMethod.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
tooltipPosition="bottom"
|
tooltipPosition="bottom"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface PostProcessingToggleProps {
|
||||||
|
|
||||||
export const PostProcessingToggle: React.FC<PostProcessingToggleProps> =
|
export const PostProcessingToggle: React.FC<PostProcessingToggleProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const enabled = getSetting("post_process_enabled") || false;
|
const enabled = getSetting("post_process_enabled") || false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const PostProcessingToggle: React.FC<PostProcessingToggleProps> =
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(enabled) => updateSetting("post_process_enabled", enabled)}
|
onChange={(enabled) => updateSetting("post_process_enabled", enabled)}
|
||||||
isUpdating={isUpdating("post_process_enabled")}
|
isUpdating={isUpdating("post_process_enabled")}
|
||||||
label="Post Process"
|
label={t("settings.debug.postProcessingToggle.label")}
|
||||||
description="Enable post-processing of transcribed text using language models via OpenAI Compatible API."
|
description={t("settings.debug.postProcessingToggle.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface PushToTalkProps {
|
||||||
|
|
||||||
export const PushToTalk: React.FC<PushToTalkProps> = React.memo(
|
export const PushToTalk: React.FC<PushToTalkProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const pttEnabled = getSetting("push_to_talk") || false;
|
const pttEnabled = getSetting("push_to_talk") || false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const PushToTalk: React.FC<PushToTalkProps> = React.memo(
|
||||||
checked={pttEnabled}
|
checked={pttEnabled}
|
||||||
onChange={(enabled) => updateSetting("push_to_talk", enabled)}
|
onChange={(enabled) => updateSetting("push_to_talk", enabled)}
|
||||||
isUpdating={isUpdating("push_to_talk")}
|
isUpdating={isUpdating("push_to_talk")}
|
||||||
label="Push To Talk"
|
label={t("settings.general.pushToTalk.label")}
|
||||||
description="Hold to record, release to stop"
|
description={t("settings.general.pushToTalk.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
@ -11,6 +12,7 @@ interface RecordingRetentionPeriodProps {
|
||||||
|
|
||||||
export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriodProps> =
|
export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriodProps> =
|
||||||
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const selectedRetentionPeriod =
|
const selectedRetentionPeriod =
|
||||||
|
|
@ -25,17 +27,25 @@ export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriod
|
||||||
};
|
};
|
||||||
|
|
||||||
const retentionOptions = [
|
const retentionOptions = [
|
||||||
{ value: "never", label: "Never" },
|
{ value: "never", label: t("settings.debug.recordingRetention.never") },
|
||||||
{ value: "preserve_limit", label: `Preserve ${historyLimit} Recordings` },
|
{
|
||||||
{ value: "days3", label: "After 3 Days" },
|
value: "preserve_limit",
|
||||||
{ value: "weeks2", label: "After 2 Weeks" },
|
label: t("settings.debug.recordingRetention.preserveLimit", {
|
||||||
{ value: "months3", label: "After 3 Months" },
|
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 (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Delete Recordings"
|
title={t("settings.debug.recordingRetention.title")}
|
||||||
description="Automatically delete recordings from the device"
|
description={t("settings.debug.recordingRetention.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
@ -43,7 +53,7 @@ export const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriod
|
||||||
options={retentionOptions}
|
options={retentionOptions}
|
||||||
selectedValue={selectedRetentionPeriod}
|
selectedValue={selectedRetentionPeriod}
|
||||||
onSelect={handleRetentionPeriodSelect}
|
onSelect={handleRetentionPeriodSelect}
|
||||||
placeholder="Select retention period..."
|
placeholder={t("settings.debug.recordingRetention.placeholder")}
|
||||||
disabled={isUpdating("recording_retention_period")}
|
disabled={isUpdating("recording_retention_period")}
|
||||||
/>
|
/>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
@ -9,23 +10,24 @@ interface ShowOverlayProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlayOptions = [
|
|
||||||
{ value: "none", label: "None" },
|
|
||||||
{ value: "bottom", label: "Bottom" },
|
|
||||||
{ value: "top", label: "Top" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ShowOverlay: React.FC<ShowOverlayProps> = React.memo(
|
export const ShowOverlay: React.FC<ShowOverlayProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
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") ||
|
const selectedPosition = (getSetting("overlay_position") ||
|
||||||
"bottom") as OverlayPosition;
|
"bottom") as OverlayPosition;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Overlay Position"
|
title={t("settings.advanced.overlay.title")}
|
||||||
description="Display visual feedback overlay during recording and transcription. On Linux 'None' is recommended."
|
description={t("settings.advanced.overlay.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface StartHiddenProps {
|
||||||
|
|
||||||
export const StartHidden: React.FC<StartHiddenProps> = React.memo(
|
export const StartHidden: React.FC<StartHiddenProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
const startHidden = getSetting("start_hidden") ?? false;
|
const startHidden = getSetting("start_hidden") ?? false;
|
||||||
|
|
@ -18,8 +20,8 @@ export const StartHidden: React.FC<StartHiddenProps> = React.memo(
|
||||||
checked={startHidden}
|
checked={startHidden}
|
||||||
onChange={(enabled) => updateSetting("start_hidden", enabled)}
|
onChange={(enabled) => updateSetting("start_hidden", enabled)}
|
||||||
isUpdating={isUpdating("start_hidden")}
|
isUpdating={isUpdating("start_hidden")}
|
||||||
label="Start Hidden"
|
label={t("settings.advanced.startHidden.label")}
|
||||||
description="Launch to system tray without opening the window."
|
description={t("settings.advanced.startHidden.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
tooltipPosition="bottom"
|
tooltipPosition="bottom"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
@ -17,6 +18,7 @@ const unsupportedTranslationModels = [
|
||||||
|
|
||||||
export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
|
export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
|
||||||
({ descriptionMode = "tooltip", grouped = false }) => {
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const { currentModel, loadCurrentModel, models } = useModels();
|
const { currentModel, loadCurrentModel, models } = useModels();
|
||||||
|
|
||||||
|
|
@ -29,11 +31,16 @@ export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
|
||||||
const currentModelDisplayName = models.find(
|
const currentModelDisplayName = models.find(
|
||||||
(model) => model.id === currentModel,
|
(model) => model.id === currentModel,
|
||||||
)?.name;
|
)?.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.";
|
return t("settings.advanced.translateToEnglish.description");
|
||||||
}, [models, currentModel, isDisabledTranslation]);
|
}, [t, models, currentModel, isDisabledTranslation]);
|
||||||
|
|
||||||
// Listen for model state changes to update UI reactively
|
// Listen for model state changes to update UI reactively
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -52,7 +59,7 @@ export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(
|
||||||
onChange={(enabled) => updateSetting("translate_to_english", enabled)}
|
onChange={(enabled) => updateSetting("translate_to_english", enabled)}
|
||||||
isUpdating={isUpdating("translate_to_english")}
|
isUpdating={isUpdating("translate_to_english")}
|
||||||
disabled={isDisabledTranslation}
|
disabled={isDisabledTranslation}
|
||||||
label="Translate to English"
|
label={t("settings.advanced.translateToEnglish.label")}
|
||||||
description={description}
|
description={description}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ export const UpdateChecksToggle: React.FC<UpdateChecksToggleProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating } = useSettings();
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
const updateChecksEnabled = getSetting("update_checks_enabled") ?? true;
|
const updateChecksEnabled = getSetting("update_checks_enabled") ?? true;
|
||||||
|
|
||||||
|
|
@ -19,8 +21,8 @@ export const UpdateChecksToggle: React.FC<UpdateChecksToggleProps> = ({
|
||||||
checked={updateChecksEnabled}
|
checked={updateChecksEnabled}
|
||||||
onChange={(enabled) => updateSetting("update_checks_enabled", enabled)}
|
onChange={(enabled) => updateSetting("update_checks_enabled", enabled)}
|
||||||
isUpdating={isUpdating("update_checks_enabled")}
|
isUpdating={isUpdating("update_checks_enabled")}
|
||||||
label="Check for Updates"
|
label={t("settings.debug.updateChecks.label")}
|
||||||
description="Allow Handy to automatically check for updates and enable manual checks from the footer or tray menu."
|
description={t("settings.debug.updateChecks.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Slider } from "../ui/Slider";
|
import { Slider } from "../ui/Slider";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
|
export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting } = useSettings();
|
const { getSetting, updateSetting } = useSettings();
|
||||||
const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5;
|
const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5;
|
||||||
|
|
||||||
|
|
@ -17,8 +19,8 @@ export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
label="Volume"
|
label={t("settings.sound.volume.title")}
|
||||||
description="Adjust the volume of audio feedback sounds"
|
description={t("settings.sound.volume.description")}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
grouped
|
grouped
|
||||||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { SettingsGroup } from "../../ui/SettingsGroup";
|
import { SettingsGroup } from "../../ui/SettingsGroup";
|
||||||
import { SettingContainer } from "../../ui/SettingContainer";
|
import { SettingContainer } from "../../ui/SettingContainer";
|
||||||
import { Button } from "../../ui/Button";
|
import { Button } from "../../ui/Button";
|
||||||
import { AppDataDirectory } from "../AppDataDirectory";
|
import { AppDataDirectory } from "../AppDataDirectory";
|
||||||
|
import { AppLanguageSelector } from "../AppLanguageSelector";
|
||||||
|
|
||||||
export const AboutSettings: React.FC = () => {
|
export const AboutSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -33,20 +36,22 @@ export const AboutSettings: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<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
|
<SettingContainer
|
||||||
title="Version"
|
title={t("settings.about.version.title")}
|
||||||
description="Current version of Handy"
|
description={t("settings.about.version.description")}
|
||||||
grouped={true}
|
grouped={true}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||||
<span className="text-sm font-mono">v{version}</span>
|
<span className="text-sm font-mono">v{version}</span>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
||||||
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
|
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
|
||||||
|
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Source Code"
|
title={t("settings.about.sourceCode.title")}
|
||||||
description="View source code and contribute"
|
description={t("settings.about.sourceCode.description")}
|
||||||
grouped={true}
|
grouped={true}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -54,31 +59,30 @@ export const AboutSettings: React.FC = () => {
|
||||||
size="md"
|
size="md"
|
||||||
onClick={() => openUrl("https://github.com/cjpais/Handy")}
|
onClick={() => openUrl("https://github.com/cjpais/Handy")}
|
||||||
>
|
>
|
||||||
View on GitHub
|
{t("settings.about.sourceCode.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Support Development"
|
title={t("settings.about.supportDevelopment.title")}
|
||||||
description="Help us continue building Handy"
|
description={t("settings.about.supportDevelopment.description")}
|
||||||
grouped={true}
|
grouped={true}
|
||||||
>
|
>
|
||||||
<Button variant="primary" size="md" onClick={handleDonateClick}>
|
<Button variant="primary" size="md" onClick={handleDonateClick}>
|
||||||
Donate
|
{t("settings.about.supportDevelopment.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup title="Acknowledgments">
|
<SettingsGroup title={t("settings.about.acknowledgments.title")}>
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Whisper.cpp"
|
title={t("settings.about.acknowledgments.whisper.title")}
|
||||||
description="High-performance inference of OpenAI's Whisper automatic speech recognition model"
|
description={t("settings.about.acknowledgments.whisper.description")}
|
||||||
grouped={true}
|
grouped={true}
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
>
|
>
|
||||||
<div className="text-sm text-mid-gray">
|
<div className="text-sm text-mid-gray">
|
||||||
Handy uses Whisper.cpp for fast, local speech-to-text processing.
|
{t("settings.about.acknowledgments.whisper.details")}
|
||||||
Thanks to the amazing work by Georgi Gerganov and contributors.
|
|
||||||
</div>
|
</div>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ShowOverlay } from "../ShowOverlay";
|
import { ShowOverlay } from "../ShowOverlay";
|
||||||
import { TranslateToEnglish } from "../TranslateToEnglish";
|
import { TranslateToEnglish } from "../TranslateToEnglish";
|
||||||
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
|
import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout";
|
||||||
|
|
@ -10,9 +11,10 @@ import { PasteMethodSetting } from "../PasteMethod";
|
||||||
import { ClipboardHandlingSetting } from "../ClipboardHandling";
|
import { ClipboardHandlingSetting } from "../ClipboardHandling";
|
||||||
|
|
||||||
export const AdvancedSettings: React.FC = () => {
|
export const AdvancedSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<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} />
|
<StartHidden descriptionMode="tooltip" grouped={true} />
|
||||||
<AutostartToggle descriptionMode="tooltip" grouped={true} />
|
<AutostartToggle descriptionMode="tooltip" grouped={true} />
|
||||||
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingContainer } from "../../ui/SettingContainer";
|
import { SettingContainer } from "../../ui/SettingContainer";
|
||||||
|
|
||||||
interface DebugPathsProps {
|
interface DebugPathsProps {
|
||||||
|
|
@ -10,6 +11,8 @@ export const DebugPaths: React.FC<DebugPathsProps> = ({
|
||||||
descriptionMode = "inline",
|
descriptionMode = "inline",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Debug Paths"
|
title="Debug Paths"
|
||||||
|
|
@ -19,15 +22,24 @@ export const DebugPaths: React.FC<DebugPathsProps> = ({
|
||||||
>
|
>
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
<div>
|
<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>
|
<span className="font-mono text-xs">%APPDATA%/handy</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span className="font-mono text-xs">%APPDATA%/handy/models</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<span className="font-mono text-xs">
|
||||||
%APPDATA%/handy/settings_store.json
|
%APPDATA%/handy/settings_store.json
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import { WordCorrectionThreshold } from "./WordCorrectionThreshold";
|
import { WordCorrectionThreshold } from "./WordCorrectionThreshold";
|
||||||
import { LogDirectory } from "./LogDirectory";
|
import { LogDirectory } from "./LogDirectory";
|
||||||
|
|
@ -17,19 +18,20 @@ import { UpdateChecksToggle } from "../UpdateChecksToggle";
|
||||||
import { useSettings } from "../../../hooks/useSettings";
|
import { useSettings } from "../../../hooks/useSettings";
|
||||||
|
|
||||||
export const DebugSettings: React.FC = () => {
|
export const DebugSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting } = useSettings();
|
const { getSetting } = useSettings();
|
||||||
const pushToTalk = getSetting("push_to_talk");
|
const pushToTalk = getSetting("push_to_talk");
|
||||||
const isLinux = type() === "linux";
|
const isLinux = type() === "linux";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<div className="max-w-3xl w-full mx-auto space-y-6">
|
||||||
<SettingsGroup title="Debug">
|
<SettingsGroup title={t("settings.debug.title")}>
|
||||||
<LogDirectory grouped={true} />
|
<LogDirectory grouped={true} />
|
||||||
<LogLevelSelector grouped={true} />
|
<LogLevelSelector grouped={true} />
|
||||||
<UpdateChecksToggle descriptionMode="tooltip" grouped={true} />
|
<UpdateChecksToggle descriptionMode="tooltip" grouped={true} />
|
||||||
<SoundPicker
|
<SoundPicker
|
||||||
label="Sound Theme"
|
label={t("settings.debug.soundTheme.label")}
|
||||||
description="Choose a sound theme for recording start and stop feedback"
|
description={t("settings.debug.soundTheme.description")}
|
||||||
/>
|
/>
|
||||||
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
|
||||||
<HistoryLimit descriptionMode="tooltip" grouped={true} />
|
<HistoryLimit descriptionMode="tooltip" grouped={true} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { commands } from "@/bindings";
|
import { commands } from "@/bindings";
|
||||||
import { SettingContainer } from "../../ui/SettingContainer";
|
import { SettingContainer } from "../../ui/SettingContainer";
|
||||||
import { Button } from "../../ui/Button";
|
import { Button } from "../../ui/Button";
|
||||||
|
|
@ -12,6 +13,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [logDir, setLogDir] = useState<string>("");
|
const [logDir, setLogDir] = useState<string>("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -50,8 +52,8 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Log Directory"
|
title={t("settings.debug.logDirectory.title")}
|
||||||
description="Location on disk where Handy writes rotated log files"
|
description={t("settings.debug.logDirectory.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
|
|
@ -62,7 +64,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-xs text-red-600">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -76,7 +78,7 @@ export const LogDirectory: React.FC<LogDirectoryProps> = ({
|
||||||
disabled={!logDir}
|
disabled={!logDir}
|
||||||
className="px-3 py-2"
|
className="px-3 py-2"
|
||||||
>
|
>
|
||||||
Open
|
{t("common.open")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingContainer } from "../../ui/SettingContainer";
|
import { SettingContainer } from "../../ui/SettingContainer";
|
||||||
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
|
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
|
||||||
import { useSettings } from "../../../hooks/useSettings";
|
import { useSettings } from "../../../hooks/useSettings";
|
||||||
|
|
@ -21,6 +22,7 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { settings, updateSetting, isUpdating } = useSettings();
|
const { settings, updateSetting, isUpdating } = useSettings();
|
||||||
const currentLevel = settings?.log_level ?? "debug";
|
const currentLevel = settings?.log_level ?? "debug";
|
||||||
|
|
||||||
|
|
@ -36,8 +38,8 @@ export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Log Level"
|
title={t("settings.debug.logLevel.title")}
|
||||||
description="Choose how verbose Handy should be while logging to disk"
|
description={t("settings.debug.logLevel.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Slider } from "../../ui/Slider";
|
import { Slider } from "../../ui/Slider";
|
||||||
import { useSettings } from "../../../hooks/useSettings";
|
import { useSettings } from "../../../hooks/useSettings";
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ interface WordCorrectionThresholdProps {
|
||||||
export const WordCorrectionThreshold: React.FC<
|
export const WordCorrectionThreshold: React.FC<
|
||||||
WordCorrectionThresholdProps
|
WordCorrectionThresholdProps
|
||||||
> = ({ descriptionMode = "tooltip", grouped = false }) => {
|
> = ({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { settings, updateSetting } = useSettings();
|
const { settings, updateSetting } = useSettings();
|
||||||
|
|
||||||
const handleThresholdChange = (value: number) => {
|
const handleThresholdChange = (value: number) => {
|
||||||
|
|
@ -22,8 +24,8 @@ export const WordCorrectionThreshold: React.FC<
|
||||||
onChange={handleThresholdChange}
|
onChange={handleThresholdChange}
|
||||||
min={0.0}
|
min={0.0}
|
||||||
max={1.0}
|
max={1.0}
|
||||||
label="Word Correction Threshold"
|
label={t("settings.debug.wordCorrectionThreshold.title")}
|
||||||
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)."
|
description={t("settings.debug.wordCorrectionThreshold.description")}
|
||||||
descriptionMode={descriptionMode}
|
descriptionMode={descriptionMode}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { MicrophoneSelector } from "../MicrophoneSelector";
|
import { MicrophoneSelector } from "../MicrophoneSelector";
|
||||||
import { LanguageSelector } from "../LanguageSelector";
|
import { LanguageSelector } from "../LanguageSelector";
|
||||||
import { HandyShortcut } from "../HandyShortcut";
|
import { HandyShortcut } from "../HandyShortcut";
|
||||||
|
|
@ -10,15 +11,16 @@ import { useSettings } from "../../../hooks/useSettings";
|
||||||
import { VolumeSlider } from "../VolumeSlider";
|
import { VolumeSlider } from "../VolumeSlider";
|
||||||
|
|
||||||
export const GeneralSettings: React.FC = () => {
|
export const GeneralSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { audioFeedbackEnabled } = useSettings();
|
const { audioFeedbackEnabled } = useSettings();
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<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} />
|
<HandyShortcut shortcutId="transcribe" grouped={true} />
|
||||||
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
<LanguageSelector descriptionMode="tooltip" grouped={true} />
|
||||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
<SettingsGroup title="Sound">
|
<SettingsGroup title={t("settings.sound.title")}>
|
||||||
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
|
||||||
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
||||||
<OutputDeviceSelector
|
<OutputDeviceSelector
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,36 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { AudioPlayer } from "../../ui/AudioPlayer";
|
import { AudioPlayer } from "../../ui/AudioPlayer";
|
||||||
import { Button } from "../../ui/Button";
|
import { Button } from "../../ui/Button";
|
||||||
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
|
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { commands, type HistoryEntry } from "@/bindings";
|
import { commands, type HistoryEntry } from "@/bindings";
|
||||||
|
import { formatDateTime } from "@/utils/dateFormat";
|
||||||
|
|
||||||
interface OpenRecordingsButtonProps {
|
interface OpenRecordingsButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
|
const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
|
||||||
onClick,
|
onClick,
|
||||||
|
label,
|
||||||
}) => (
|
}) => (
|
||||||
<Button
|
<Button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
title="Open recordings folder"
|
title={label}
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
<span>Open Recordings Folder</span>
|
<span>{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HistorySettings: React.FC = () => {
|
export const HistorySettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -121,14 +126,17 @@ export const HistorySettings: React.FC = () => {
|
||||||
<div className="px-4 flex items-center justify-between">
|
<div className="px-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
||||||
History
|
{t("settings.history.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<OpenRecordingsButton onClick={openRecordingsFolder} />
|
<OpenRecordingsButton
|
||||||
|
onClick={openRecordingsFolder}
|
||||||
|
label={t("settings.history.openFolder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||||
<div className="px-4 py-3 text-center text-text/60">
|
<div className="px-4 py-3 text-center text-text/60">
|
||||||
Loading history...
|
{t("settings.history.loading")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,14 +151,17 @@ export const HistorySettings: React.FC = () => {
|
||||||
<div className="px-4 flex items-center justify-between">
|
<div className="px-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
||||||
History
|
{t("settings.history.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<OpenRecordingsButton onClick={openRecordingsFolder} />
|
<OpenRecordingsButton
|
||||||
|
onClick={openRecordingsFolder}
|
||||||
|
label={t("settings.history.openFolder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||||
<div className="px-4 py-3 text-center text-text/60">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,10 +175,13 @@ export const HistorySettings: React.FC = () => {
|
||||||
<div className="px-4 flex items-center justify-between">
|
<div className="px-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
||||||
History
|
{t("settings.history.title")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<OpenRecordingsButton onClick={openRecordingsFolder} />
|
<OpenRecordingsButton
|
||||||
|
onClick={openRecordingsFolder}
|
||||||
|
label={t("settings.history.openFolder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||||
<div className="divide-y divide-mid-gray/20">
|
<div className="divide-y divide-mid-gray/20">
|
||||||
|
|
@ -203,6 +217,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
getAudioUrl,
|
getAudioUrl,
|
||||||
deleteAudio,
|
deleteAudio,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
const [showCopied, setShowCopied] = useState(false);
|
const [showCopied, setShowCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -229,15 +244,17 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formattedDate = formatDateTime(entry.timestamp, i18n.language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
|
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyText}
|
onClick={handleCopyText}
|
||||||
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
|
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 ? (
|
{showCopied ? (
|
||||||
<Check width={16} height={16} />
|
<Check width={16} height={16} />
|
||||||
|
|
@ -252,7 +269,11 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
? "text-logo-primary hover:text-logo-primary/80"
|
? "text-logo-primary hover:text-logo-primary/80"
|
||||||
: "text-text/50 hover:text-logo-primary"
|
: "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
|
<Star
|
||||||
width={16}
|
width={16}
|
||||||
|
|
@ -263,7 +284,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteEntry}
|
onClick={handleDeleteEntry}
|
||||||
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
|
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} />
|
<Trash2 width={16} height={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { RefreshCcw } from "lucide-react";
|
import { RefreshCcw } from "lucide-react";
|
||||||
import { commands } from "@/bindings";
|
import { commands } from "@/bindings";
|
||||||
|
|
||||||
|
|
@ -26,13 +27,13 @@ const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const PostProcessingSettingsApiComponent: React.FC = () => {
|
const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = usePostProcessProviderState();
|
const state = usePostProcessProviderState();
|
||||||
|
|
||||||
if (!state.enabled) {
|
if (!state.enabled) {
|
||||||
return (
|
return (
|
||||||
<DisabledNotice>
|
<DisabledNotice>
|
||||||
Post processing is currently disabled. Enable it in Debug settings to
|
{t("settings.postProcessing.disabledNotice")}
|
||||||
configure.
|
|
||||||
</DisabledNotice>
|
</DisabledNotice>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -40,8 +41,8 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Provider"
|
title={t("settings.postProcessing.api.provider.title")}
|
||||||
description="Select an OpenAI-compatible provider."
|
description={t("settings.postProcessing.api.provider.description")}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
grouped={true}
|
grouped={true}
|
||||||
|
|
@ -57,22 +58,23 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
|
|
||||||
{state.isAppleProvider ? (
|
{state.isAppleProvider ? (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Apple Intelligence"
|
title={t("settings.postProcessing.api.appleIntelligence.title")}
|
||||||
description="Runs fully on-device. No API key or network access is required."
|
description={t(
|
||||||
|
"settings.postProcessing.api.appleIntelligence.description",
|
||||||
|
)}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
grouped={true}
|
grouped={true}
|
||||||
>
|
>
|
||||||
<DisabledNotice>
|
<DisabledNotice>
|
||||||
Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later.
|
{t("settings.postProcessing.api.appleIntelligence.requirements")}
|
||||||
Apple Intelligence must be enabled in System Settings.
|
|
||||||
</DisabledNotice>
|
</DisabledNotice>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Base URL"
|
title={t("settings.postProcessing.api.baseUrl.title")}
|
||||||
description="API base URL for the selected provider. Only the custom provider can be edited."
|
description={t("settings.postProcessing.api.baseUrl.description")}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
grouped={true}
|
grouped={true}
|
||||||
|
|
@ -81,7 +83,9 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
<BaseUrlField
|
<BaseUrlField
|
||||||
value={state.baseUrl}
|
value={state.baseUrl}
|
||||||
onBlur={state.handleBaseUrlChange}
|
onBlur={state.handleBaseUrlChange}
|
||||||
placeholder="https://api.openai.com/v1"
|
placeholder={t(
|
||||||
|
"settings.postProcessing.api.baseUrl.placeholder",
|
||||||
|
)}
|
||||||
disabled={
|
disabled={
|
||||||
!state.selectedProvider?.allow_base_url_edit ||
|
!state.selectedProvider?.allow_base_url_edit ||
|
||||||
state.isBaseUrlUpdating
|
state.isBaseUrlUpdating
|
||||||
|
|
@ -92,8 +96,8 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="API Key"
|
title={t("settings.postProcessing.api.apiKey.title")}
|
||||||
description="API key for the selected provider."
|
description={t("settings.postProcessing.api.apiKey.description")}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
grouped={true}
|
grouped={true}
|
||||||
|
|
@ -102,7 +106,9 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
<ApiKeyField
|
<ApiKeyField
|
||||||
value={state.apiKey}
|
value={state.apiKey}
|
||||||
onBlur={state.handleApiKeyChange}
|
onBlur={state.handleApiKeyChange}
|
||||||
placeholder="sk-..."
|
placeholder={t(
|
||||||
|
"settings.postProcessing.api.apiKey.placeholder",
|
||||||
|
)}
|
||||||
disabled={state.isApiKeyUpdating}
|
disabled={state.isApiKeyUpdating}
|
||||||
className="min-w-[320px]"
|
className="min-w-[320px]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -112,13 +118,13 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Model"
|
title={t("settings.postProcessing.api.model.title")}
|
||||||
description={
|
description={
|
||||||
state.isAppleProvider
|
state.isAppleProvider
|
||||||
? "Provide an optional numeric token limit or keep the default on-device preset."
|
? t("settings.postProcessing.api.model.descriptionApple")
|
||||||
: state.isCustomProvider
|
: state.isCustomProvider
|
||||||
? "Provide the model identifier expected by your custom endpoint."
|
? t("settings.postProcessing.api.model.descriptionCustom")
|
||||||
: "Choose a model exposed by the selected provider."
|
: t("settings.postProcessing.api.model.descriptionDefault")
|
||||||
}
|
}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
|
|
@ -132,10 +138,12 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
isLoading={state.isFetchingModels}
|
isLoading={state.isFetchingModels}
|
||||||
placeholder={
|
placeholder={
|
||||||
state.isAppleProvider
|
state.isAppleProvider
|
||||||
? "Apple Intelligence"
|
? t("settings.postProcessing.api.model.placeholderApple")
|
||||||
: state.modelOptions.length > 0
|
: state.modelOptions.length > 0
|
||||||
? "Search or select a model"
|
? t(
|
||||||
: "Type a model name"
|
"settings.postProcessing.api.model.placeholderWithOptions",
|
||||||
|
)
|
||||||
|
: t("settings.postProcessing.api.model.placeholderNoOptions")
|
||||||
}
|
}
|
||||||
onSelect={state.handleModelSelect}
|
onSelect={state.handleModelSelect}
|
||||||
onCreate={state.handleModelCreate}
|
onCreate={state.handleModelCreate}
|
||||||
|
|
@ -145,7 +153,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
<ResetButton
|
<ResetButton
|
||||||
onClick={state.handleRefreshModels}
|
onClick={state.handleRefreshModels}
|
||||||
disabled={state.isFetchingModels || state.isAppleProvider}
|
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"
|
className="flex h-10 w-10 items-center justify-center"
|
||||||
>
|
>
|
||||||
<RefreshCcw
|
<RefreshCcw
|
||||||
|
|
@ -159,6 +167,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getSetting, updateSetting, isUpdating, refreshSettings } =
|
const { getSetting, updateSetting, isUpdating, refreshSettings } =
|
||||||
useSettings();
|
useSettings();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
@ -259,8 +268,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return (
|
return (
|
||||||
<DisabledNotice>
|
<DisabledNotice>
|
||||||
Post processing is currently disabled. Enable it in Debug settings to
|
{t("settings.postProcessing.disabledNotice")}
|
||||||
configure.
|
|
||||||
</DisabledNotice>
|
</DisabledNotice>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -273,8 +281,10 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer
|
<SettingContainer
|
||||||
title="Selected Prompt"
|
title={t("settings.postProcessing.prompts.selectedPrompt.title")}
|
||||||
description="Select a template for refining transcriptions or create a new one. Use ${output} inside the prompt text to reference the captured transcript."
|
description={t(
|
||||||
|
"settings.postProcessing.prompts.selectedPrompt.description",
|
||||||
|
)}
|
||||||
descriptionMode="tooltip"
|
descriptionMode="tooltip"
|
||||||
layout="stacked"
|
layout="stacked"
|
||||||
grouped={true}
|
grouped={true}
|
||||||
|
|
@ -289,7 +299,9 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
}))}
|
}))}
|
||||||
onSelect={(value) => handlePromptSelect(value)}
|
onSelect={(value) => handlePromptSelect(value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
prompts.length === 0 ? "No prompts available" : "Select a prompt"
|
prompts.length === 0
|
||||||
|
? t("settings.postProcessing.prompts.noPrompts")
|
||||||
|
: t("settings.postProcessing.prompts.selectPrompt")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdating("post_process_selected_prompt_id") || isCreating
|
isUpdating("post_process_selected_prompt_id") || isCreating
|
||||||
|
|
@ -302,39 +314,44 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
size="md"
|
size="md"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
>
|
>
|
||||||
Create New Prompt
|
{t("settings.postProcessing.prompts.createNew")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCreating && hasPrompts && selectedPrompt && (
|
{!isCreating && hasPrompts && selectedPrompt && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2 flex flex-col">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={draftName}
|
value={draftName}
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
placeholder="Enter prompt name"
|
placeholder={t(
|
||||||
|
"settings.postProcessing.prompts.promptLabelPlaceholder",
|
||||||
|
)}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<label className="text-sm font-semibold">
|
<label className="text-sm font-semibold">
|
||||||
Prompt Instructions
|
{t("settings.postProcessing.prompts.promptInstructions")}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draftText}
|
value={draftText}
|
||||||
onChange={(e) => setDraftText(e.target.value)}
|
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">
|
|
||||||
${output}
|
|
||||||
</code>{" "}
|
|
||||||
to insert the transcribed text in your prompt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
|
|
@ -344,7 +361,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
size="md"
|
size="md"
|
||||||
disabled={!draftName.trim() || !draftText.trim() || !isDirty}
|
disabled={!draftName.trim() || !draftText.trim() || !isDirty}
|
||||||
>
|
>
|
||||||
Update Prompt
|
{t("settings.postProcessing.prompts.updatePrompt")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDeletePrompt(selectedPromptId)}
|
onClick={() => handleDeletePrompt(selectedPromptId)}
|
||||||
|
|
@ -352,7 +369,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
size="md"
|
size="md"
|
||||||
disabled={!selectedPromptId || prompts.length <= 1}
|
disabled={!selectedPromptId || prompts.length <= 1}
|
||||||
>
|
>
|
||||||
Delete Prompt
|
{t("settings.postProcessing.prompts.deletePrompt")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,8 +379,8 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
<div className="p-3 bg-mid-gray/5 rounded border border-mid-gray/20">
|
<div className="p-3 bg-mid-gray/5 rounded border border-mid-gray/20">
|
||||||
<p className="text-sm text-mid-gray">
|
<p className="text-sm text-mid-gray">
|
||||||
{hasPrompts
|
{hasPrompts
|
||||||
? "Select a prompt above to view and edit its details."
|
? t("settings.postProcessing.prompts.selectToEdit")
|
||||||
: "Click 'Create New Prompt' above to create your first post-processing prompt."}
|
: t("settings.postProcessing.prompts.createFirst")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -372,33 +389,36 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2 block flex flex-col">
|
<div className="space-y-2 block flex flex-col">
|
||||||
<label className="text-sm font-semibold text-text">
|
<label className="text-sm font-semibold text-text">
|
||||||
Prompt Label
|
{t("settings.postProcessing.prompts.promptLabel")}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={draftName}
|
value={draftName}
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
placeholder="Enter prompt name"
|
placeholder={t(
|
||||||
|
"settings.postProcessing.prompts.promptLabelPlaceholder",
|
||||||
|
)}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<label className="text-sm font-semibold">
|
<label className="text-sm font-semibold">
|
||||||
Prompt Instructions
|
{t("settings.postProcessing.prompts.promptInstructions")}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draftText}
|
value={draftText}
|
||||||
onChange={(e) => setDraftText(e.target.value)}
|
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">
|
|
||||||
${output}
|
|
||||||
</code>{" "}
|
|
||||||
to insert the transcribed text in your prompt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
|
|
@ -408,14 +428,14 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => {
|
||||||
size="md"
|
size="md"
|
||||||
disabled={!draftName.trim() || !draftText.trim()}
|
disabled={!draftName.trim() || !draftText.trim()}
|
||||||
>
|
>
|
||||||
Create Prompt
|
{t("settings.postProcessing.prompts.createPrompt")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancelCreate}
|
onClick={handleCancelCreate}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("settings.postProcessing.prompts.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -436,13 +456,15 @@ export const PostProcessingSettingsPrompts = React.memo(
|
||||||
PostProcessingSettingsPrompts.displayName = "PostProcessingSettingsPrompts";
|
PostProcessingSettingsPrompts.displayName = "PostProcessingSettingsPrompts";
|
||||||
|
|
||||||
export const PostProcessingSettings: React.FC = () => {
|
export const PostProcessingSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<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 />
|
<PostProcessingSettingsApi />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup title="Prompt">
|
<SettingsGroup title={t("settings.postProcessing.prompts.title")}>
|
||||||
<PostProcessingSettingsPrompts />
|
<PostProcessingSettingsPrompts />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||||
<span className="mr-2">{item.label}</span>
|
<span className="mr-2">{item.label}</span>
|
||||||
)}
|
)}
|
||||||
{showSpeed && item.speed !== undefined && item.speed > 0 ? (
|
{showSpeed && item.speed !== undefined && item.speed > 0 ? (
|
||||||
|
// eslint-disable-next-line i18next/no-literal-string
|
||||||
<span>{item.speed.toFixed(1)}MB/s</span>
|
<span>{item.speed.toFixed(1)}MB/s</span>
|
||||||
) : showSpeed ? (
|
) : showSpeed ? (
|
||||||
<span>Downloading...</span>
|
<span>Downloading...</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -25,6 +26,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
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">
|
<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 ? (
|
{options.length === 0 ? (
|
||||||
<div className="px-2 py-1 text-sm text-mid-gray">
|
<div className="px-2 py-1 text-sm text-mid-gray">
|
||||||
No options found
|
{t("common.noOptionsFound")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
@ -10,6 +11,7 @@ interface UpdateCheckerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
|
const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Update checking state
|
// Update checking state
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
|
@ -140,19 +142,21 @@ const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
|
||||||
// Update status functions
|
// Update status functions
|
||||||
const getUpdateStatusText = () => {
|
const getUpdateStatusText = () => {
|
||||||
if (!updateChecksEnabled) {
|
if (!updateChecksEnabled) {
|
||||||
return "Update Checking Disabled";
|
return t("footer.updateCheckingDisabled");
|
||||||
}
|
}
|
||||||
if (isInstalling) {
|
if (isInstalling) {
|
||||||
return downloadProgress > 0 && downloadProgress < 100
|
return downloadProgress > 0 && downloadProgress < 100
|
||||||
? `Downloading... ${downloadProgress.toString().padStart(3)}%`
|
? t("footer.downloading", {
|
||||||
|
progress: downloadProgress.toString().padStart(3),
|
||||||
|
})
|
||||||
: downloadProgress === 100
|
: downloadProgress === 100
|
||||||
? "Installing..."
|
? t("footer.installing")
|
||||||
: "Preparing...";
|
: t("footer.preparing");
|
||||||
}
|
}
|
||||||
if (isChecking) return "Checking...";
|
if (isChecking) return t("footer.checkingUpdates");
|
||||||
if (showUpToDate) return "Up to date";
|
if (showUpToDate) return t("footer.upToDate");
|
||||||
if (updateAvailable) return "Update available";
|
if (updateAvailable) return t("footer.updateAvailableShort");
|
||||||
return "Check for updates";
|
return t("footer.checkForUpdates");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUpdateStatusAction = () => {
|
const getUpdateStatusAction = () => {
|
||||||
|
|
|
||||||
84
src/i18n/index.ts
Normal file
84
src/i18n/index.ts
Normal 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
16
src/i18n/languages.ts
Normal 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" },
|
||||||
|
};
|
||||||
410
src/i18n/locales/en/translation.json
Normal file
410
src/i18n/locales/en/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
403
src/i18n/locales/es/translation.json
Normal file
403
src/i18n/locales/es/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
404
src/i18n/locales/fr/translation.json
Normal file
404
src/i18n/locales/fr/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
404
src/i18n/locales/vi/translation.json
Normal file
404
src/i18n/locales/vi/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/lib/utils/modelTranslation.ts
Normal file
29
src/lib/utils/modelTranslation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
|
// Initialize i18n
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
127
src/utils/dateFormat.ts
Normal file
127
src/utils/dateFormat.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
"@/bindings": ["./src/bindings.ts"]
|
"@/bindings": ["./src/bindings.ts"]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineConfig(async () => ({
|
||||||
// Path aliases
|
// Path aliases
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
"@/bindings": resolve(__dirname, "./src/bindings.ts"),
|
"@/bindings": resolve(__dirname, "./src/bindings.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue