From 7928826d3db3a6b0abbe46f6ab542db390ec0cbd Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 11 Dec 2025 17:28:38 +0700 Subject: [PATCH 1/6] basic i18n --- CONTRIBUTING_TRANSLATIONS.md | 167 +++++++++ bun.lock | 13 +- package.json | 2 + src/components/Sidebar.tsx | 18 +- .../settings/AppLanguageSelector.tsx | 45 +++ .../settings/about/AboutSettings.tsx | 2 + .../settings/general/GeneralSettings.tsx | 6 +- src/i18n/index.ts | 82 +++++ src/i18n/languages.ts | 13 + src/i18n/locales/en/translation.json | 325 +++++++++++++++++ src/i18n/locales/es/translation.json | 325 +++++++++++++++++ src/i18n/locales/fr/translation.json | 326 ++++++++++++++++++ src/i18n/locales/vi/translation.json | 326 ++++++++++++++++++ src/main.tsx | 3 + 14 files changed, 1642 insertions(+), 11 deletions(-) create mode 100644 CONTRIBUTING_TRANSLATIONS.md create mode 100644 src/components/settings/AppLanguageSelector.tsx create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/languages.ts create mode 100644 src/i18n/locales/en/translation.json create mode 100644 src/i18n/locales/es/translation.json create mode 100644 src/i18n/locales/fr/translation.json create mode 100644 src/i18n/locales/vi/translation.json diff --git a/CONTRIBUTING_TRANSLATIONS.md b/CONTRIBUTING_TRANSLATIONS.md new file mode 100644 index 00000000..e0749917 --- /dev/null +++ b/CONTRIBUTING_TRANSLATIONS.md @@ -0,0 +1,167 @@ +# 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 = { + 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 +}; +``` + +That's it! The translation files are automatically discovered by Vite. + +### 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 users around the world! 🌍 diff --git a/bun.lock b/bun.lock index 5b5b03b6..16b4bdda 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "handy-app", @@ -17,9 +16,11 @@ "@tauri-apps/plugin-sql": "~2.3.1", "@tauri-apps/plugin-store": "~2.4.1", "@tauri-apps/plugin-updater": "~2.9.0", + "i18next": "^25.7.2", "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^16.4.1", "react-select": "^5.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", @@ -369,6 +370,10 @@ "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=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -451,6 +456,8 @@ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-i18next": ["react-i18next@16.4.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -495,8 +502,12 @@ "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], diff --git a/package.json b/package.json index 0179112a..c498c066 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,11 @@ "@tauri-apps/plugin-updater": "~2.9.0", "react-select": "^5.8.0", "tauri-plugin-macos-permissions-api": "2.3.0", + "i18next": "^25.7.2", "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^16.4.1", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "zod": "^3.25.76", diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 48065fc7..a156ca64 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Cog, FlaskConical, History, Info, Sparkles } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; @@ -23,7 +24,7 @@ interface IconProps { } interface SectionConfig { - label: string; + labelKey: string; icon: React.ComponentType; component: React.ComponentType; enabled: (settings: any) => boolean; @@ -31,37 +32,37 @@ interface SectionConfig { export const SECTIONS_CONFIG = { general: { - label: "General", + labelKey: "sidebar.general", icon: HandyHand, component: GeneralSettings, enabled: () => true, }, advanced: { - label: "Advanced", + labelKey: "sidebar.advanced", icon: Cog, component: AdvancedSettings, enabled: () => true, }, postprocessing: { - label: "Post Process", + labelKey: "sidebar.postProcessing", icon: Sparkles, component: PostProcessingSettings, enabled: (settings) => settings?.post_process_enabled ?? false, }, history: { - label: "History", + labelKey: "sidebar.history", icon: History, component: HistorySettings, enabled: () => true, }, debug: { - label: "Debug", + labelKey: "sidebar.debug", icon: FlaskConical, component: DebugSettings, enabled: (settings) => settings?.debug_mode ?? false, }, about: { - label: "About", + labelKey: "sidebar.about", icon: Info, component: AboutSettings, enabled: () => true, @@ -77,6 +78,7 @@ export const Sidebar: React.FC = ({ activeSection, onSectionChange, }) => { + const { t } = useTranslation(); const { settings } = useSettings(); const availableSections = Object.entries(SECTIONS_CONFIG) @@ -102,7 +104,7 @@ export const Sidebar: React.FC = ({ onClick={() => onSectionChange(section.id)} > -

{section.label}

+

{t(section.labelKey)}

); })} diff --git a/src/components/settings/AppLanguageSelector.tsx b/src/components/settings/AppLanguageSelector.tsx new file mode 100644 index 00000000..fd219bb0 --- /dev/null +++ b/src/components/settings/AppLanguageSelector.tsx @@ -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 = + 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 ( + + + + ); + }); + +AppLanguageSelector.displayName = "AppLanguageSelector"; diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index 4fcea3c8..a13620d0 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -5,6 +5,7 @@ import { SettingsGroup } from "../../ui/SettingsGroup"; import { SettingContainer } from "../../ui/SettingContainer"; import { Button } from "../../ui/Button"; import { AppDataDirectory } from "../AppDataDirectory"; +import { AppLanguageSelector } from "../AppLanguageSelector"; export const AboutSettings: React.FC = () => { const [version, setVersion] = useState(""); @@ -34,6 +35,7 @@ export const AboutSettings: React.FC = () => { return (
+ { + const { t } = useTranslation(); const { audioFeedbackEnabled } = useSettings(); return (
- + - + }>( + "./locales/*/translation.json", + { eager: true } +); + +// Build resources from discovered locale files +const resources: Record }> = {}; +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; diff --git a/src/i18n/languages.ts b/src/i18n/languages.ts new file mode 100644 index 00000000..1f1709ff --- /dev/null +++ b/src/i18n/languages.ts @@ -0,0 +1,13 @@ +/** + * 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 = { + 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" }, +}; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json new file mode 100644 index 00000000..c4a050e8 --- /dev/null +++ b/src/i18n/locales/en/translation.json @@ -0,0 +1,325 @@ +{ + "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...", + "errors": { + "loadModels": "Failed to load available models", + "downloadModel": "Failed to download model: {{error}}" + } + }, + "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...", + "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 ${output} 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" + }, + "recordingRetention": { + "title": "Recording Retention", + "description": "How long to keep audio recordings" + }, + "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" + } + }, + "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}}", + "upToDate": "Up to date", + "downloadUpdate": "Download Update", + "restart": "Restart" + }, + "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" + }, + "accessibility": { + "permissionsRequired": "Accessibility Permissions Required", + "permissionsDescription": "Handy needs accessibility permissions to type transcribed text.", + "openSettings": "Open System Settings", + "dismiss": "Dismiss" + }, + "appLanguage": { + "title": "Application Language", + "description": "Change the language of the Handy interface" + } +} diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json new file mode 100644 index 00000000..982debb6 --- /dev/null +++ b/src/i18n/locales/es/translation.json @@ -0,0 +1,325 @@ +{ + "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...", + "errors": { + "loadModels": "Error al cargar los modelos disponibles", + "downloadModel": "Error al descargar el modelo: {{error}}" + } + }, + "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...", + "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 ${output} 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" + } + }, + "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}}", + "upToDate": "Actualizado", + "downloadUpdate": "Descargar Actualización", + "restart": "Reiniciar" + }, + "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" + }, + "accessibility": { + "permissionsRequired": "Se Requieren Permisos de Accesibilidad", + "permissionsDescription": "Handy necesita permisos de accesibilidad para escribir texto transcrito.", + "openSettings": "Abrir Ajustes del Sistema", + "dismiss": "Descartar" + }, + "appLanguage": { + "title": "Idioma de la aplicación", + "description": "Cambia el idioma de la interfaz de Handy" + } +} diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json new file mode 100644 index 00000000..d73227b0 --- /dev/null +++ b/src/i18n/locales/fr/translation.json @@ -0,0 +1,326 @@ +{ + "_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...", + "errors": { + "loadModels": "Échec du chargement des modèles disponibles", + "downloadModel": "Échec du téléchargement du modèle : {{error}}" + } + }, + "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...", + "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 ${output} 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" + } + }, + "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}}", + "upToDate": "À jour", + "downloadUpdate": "Télécharger la mise à jour", + "restart": "Redémarrer" + }, + "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" + }, + "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" + }, + "appLanguage": { + "title": "Langue de l'application", + "description": "Changer la langue de l'interface de Handy" + } +} diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json new file mode 100644 index 00000000..9ca09697 --- /dev/null +++ b/src/i18n/locales/vi/translation.json @@ -0,0 +1,326 @@ +{ + "_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...", + "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}}" + } + }, + "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...", + "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 ${output} để 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" + } + }, + "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}}", + "upToDate": "Đã cập nhật", + "downloadUpdate": "Tải cập nhật", + "restart": "Khởi động lại" + }, + "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" + }, + "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" + }, + "appLanguage": { + "title": "Ngôn ngữ ứng dụng", + "description": "Thay đổi ngôn ngữ giao diện của Handy" + } +} diff --git a/src/main.tsx b/src/main.tsx index 2be325ed..63643131 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,9 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +// Initialize i18n +import "./i18n"; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( From 887f1dd1f3f4d582c1acb529920716e912df21ba Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 11 Dec 2025 17:39:28 +0700 Subject: [PATCH 2/6] tweak --- CONTRIBUTING_TRANSLATIONS.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CONTRIBUTING_TRANSLATIONS.md b/CONTRIBUTING_TRANSLATIONS.md index e0749917..a623d77a 100644 --- a/CONTRIBUTING_TRANSLATIONS.md +++ b/CONTRIBUTING_TRANSLATIONS.md @@ -81,8 +81,6 @@ export const LANGUAGE_METADATA: Record Date: Thu, 11 Dec 2025 18:37:29 +0700 Subject: [PATCH 3/6] translate app --- src/components/AccessibilityPermissions.tsx | 8 +- .../model-selector/ModelDropdown.tsx | 45 ++++--- .../model-selector/ModelSelector.tsx | 44 +++++-- src/components/onboarding/ModelCard.tsx | 20 ++- src/components/onboarding/Onboarding.tsx | 12 +- .../settings/AlwaysOnMicrophone.tsx | 6 +- src/components/settings/AppDataDirectory.tsx | 8 +- .../settings/AppendTrailingSpace.tsx | 6 +- src/components/settings/AudioFeedback.tsx | 6 +- src/components/settings/AutostartToggle.tsx | 6 +- .../settings/ClamshellMicrophoneSelector.tsx | 10 +- src/components/settings/ClipboardHandling.tsx | 16 ++- src/components/settings/CustomWords.tsx | 12 +- src/components/settings/HandyShortcut.tsx | 38 +++--- src/components/settings/HistoryLimit.tsx | 8 +- src/components/settings/LanguageSelector.tsx | 16 ++- .../settings/MicrophoneSelector.tsx | 10 +- .../settings/ModelUnloadTimeout.tsx | 36 ++--- .../settings/MuteWhileRecording.tsx | 6 +- .../settings/OutputDeviceSelector.tsx | 10 +- src/components/settings/PasteMethod.tsx | 46 +++---- .../settings/PostProcessingToggle.tsx | 6 +- src/components/settings/PushToTalk.tsx | 6 +- .../settings/RecordingRetentionPeriod.tsx | 18 +-- src/components/settings/ShowOverlay.tsx | 18 +-- src/components/settings/StartHidden.tsx | 6 +- .../settings/TranslateToEnglish.tsx | 12 +- .../settings/UpdateChecksToggle.tsx | 6 +- src/components/settings/VolumeSlider.tsx | 6 +- .../settings/about/AboutSettings.tsx | 29 ++-- .../settings/advanced/AdvancedSettings.tsx | 4 +- .../settings/debug/DebugSettings.tsx | 8 +- .../settings/debug/LogDirectory.tsx | 8 +- .../settings/debug/LogLevelSelector.tsx | 6 +- .../debug/WordCorrectionThreshold.tsx | 6 +- .../settings/history/HistorySettings.tsx | 36 +++-- .../PostProcessingSettings.tsx | 120 +++++++++-------- .../update-checker/UpdateChecker.tsx | 18 +-- src/i18n/locales/en/translation.json | 82 +++++++++++- src/i18n/locales/es/translation.json | 71 +++++++++- src/i18n/locales/fr/translation.json | 71 +++++++++- src/i18n/locales/vi/translation.json | 71 +++++++++- src/lib/utils/modelTranslation.ts | 32 +++++ src/utils/dateFormat.ts | 124 ++++++++++++++++++ tsconfig.json | 1 + vite.config.ts | 1 + 46 files changed, 852 insertions(+), 283 deletions(-) create mode 100644 src/lib/utils/modelTranslation.ts create mode 100644 src/utils/dateFormat.ts diff --git a/src/components/AccessibilityPermissions.tsx b/src/components/AccessibilityPermissions.tsx index e8abfb18..6454c8cc 100644 --- a/src/components/AccessibilityPermissions.tsx +++ b/src/components/AccessibilityPermissions.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { checkAccessibilityPermission, requestAccessibilityPermission, @@ -14,6 +15,7 @@ interface ButtonConfig { } const AccessibilityPermissions: React.FC = () => { + const { t } = useTranslation(); const [hasAccessibility, setHasAccessibility] = useState(false); const [permissionState, setPermissionState] = useState("request"); @@ -61,12 +63,12 @@ const AccessibilityPermissions: React.FC = () => { // Configure button text and style based on state const buttonConfig: Record = { request: { - text: "Grant", + text: t("accessibility.openSettings"), className: "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary", }, verify: { - text: "Verify", + text: t("accessibility.openSettings"), className: "bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded text-sm flex items-center justify-center cursor-pointer", }, @@ -80,7 +82,7 @@ const AccessibilityPermissions: React.FC = () => {

- Please grant accessibility permissions for Handy + {t("accessibility.permissionsDescription")}

diff --git a/src/components/settings/AppendTrailingSpace.tsx b/src/components/settings/AppendTrailingSpace.tsx index 9374c458..594b9488 100644 --- a/src/components/settings/AppendTrailingSpace.tsx +++ b/src/components/settings/AppendTrailingSpace.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -9,6 +10,7 @@ interface AppendTrailingSpaceProps { export const AppendTrailingSpace: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("append_trailing_space") ?? false; @@ -18,8 +20,8 @@ export const AppendTrailingSpace: React.FC = checked={enabled} onChange={(enabled) => updateSetting("append_trailing_space", enabled)} isUpdating={isUpdating("append_trailing_space")} - label="Append Trailing Space" - description="Automatically add a space at the end of transcribed text, making it easier to dictate multiple sentences in a row." + label={t("settings.debug.appendTrailingSpace.label")} + description={t("settings.debug.appendTrailingSpace.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx index 3889aa98..4ae34824 100644 --- a/src/components/settings/AudioFeedback.tsx +++ b/src/components/settings/AudioFeedback.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; import { VolumeSlider } from "./VolumeSlider"; @@ -11,6 +12,7 @@ interface AudioFeedbackProps { export const AudioFeedback: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const audioFeedbackEnabled = getSetting("audio_feedback") || false; @@ -20,8 +22,8 @@ export const AudioFeedback: React.FC = React.memo( checked={audioFeedbackEnabled} onChange={(enabled) => updateSetting("audio_feedback", enabled)} isUpdating={isUpdating("audio_feedback")} - label="Audio Feedback" - description="Play sound when recording starts and stops" + label={t("settings.sound.audioFeedback.label")} + description={t("settings.sound.audioFeedback.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/AutostartToggle.tsx b/src/components/settings/AutostartToggle.tsx index c71d9622..c5430d45 100644 --- a/src/components/settings/AutostartToggle.tsx +++ b/src/components/settings/AutostartToggle.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -9,6 +10,7 @@ interface AutostartToggleProps { export const AutostartToggle: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const autostartEnabled = getSetting("autostart_enabled") ?? false; @@ -18,8 +20,8 @@ export const AutostartToggle: React.FC = React.memo( checked={autostartEnabled} onChange={(enabled) => updateSetting("autostart_enabled", enabled)} isUpdating={isUpdating("autostart_enabled")} - label="Launch on Startup" - description="Automatically start Handy when you log in to your computer." + label={t("settings.advanced.autostart.label")} + description={t("settings.advanced.autostart.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/ClamshellMicrophoneSelector.tsx b/src/components/settings/ClamshellMicrophoneSelector.tsx index a7023803..c5a11011 100644 --- a/src/components/settings/ClamshellMicrophoneSelector.tsx +++ b/src/components/settings/ClamshellMicrophoneSelector.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { commands } from "@/bindings"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; @@ -12,6 +13,7 @@ interface ClamshellMicrophoneSelectorProps { export const ClamshellMicrophoneSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, @@ -67,8 +69,8 @@ export const ClamshellMicrophoneSelector: React.FC @@ -79,8 +81,8 @@ export const ClamshellMicrophoneSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); + const clipboardHandlingOptions = [ + { value: "dont_modify", label: t("settings.advanced.clipboardHandling.options.dontModify") }, + { value: "copy_to_clipboard", label: t("settings.advanced.clipboardHandling.options.copyToClipboard") }, + ]; + const selectedHandling = (getSetting("clipboard_handling") || "dont_modify") as ClipboardHandling; return ( diff --git a/src/components/settings/CustomWords.tsx b/src/components/settings/CustomWords.tsx index 0f059d33..bdb9e14a 100644 --- a/src/components/settings/CustomWords.tsx +++ b/src/components/settings/CustomWords.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useSettings } from "../../hooks/useSettings"; import { Input } from "../ui/Input"; import { Button } from "../ui/Button"; @@ -11,6 +12,7 @@ interface CustomWordsProps { export const CustomWords: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const [newWord, setNewWord] = useState(""); const customWords = getSetting("custom_words") || []; @@ -46,8 +48,8 @@ export const CustomWords: React.FC = React.memo( return ( <> @@ -58,7 +60,7 @@ export const CustomWords: React.FC = React.memo( value={newWord} onChange={(e) => setNewWord(e.target.value)} onKeyDown={handleKeyPress} - placeholder="Add a word" + placeholder={t("settings.advanced.customWords.placeholder")} variant="compact" disabled={isUpdating("custom_words")} /> @@ -73,7 +75,7 @@ export const CustomWords: React.FC = React.memo( variant="primary" size="md" > - Add + {t("settings.advanced.customWords.add")}
@@ -89,7 +91,7 @@ export const CustomWords: React.FC = React.memo( variant="secondary" size="sm" className="inline-flex items-center gap-1 cursor-pointer" - aria-label={`Remove ${word}`} + aria-label={t("settings.advanced.customWords.remove", { word })} > {word} = ({ shortcutId, disabled = false, }) => { + const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = useSettings(); const [keyPressed, setKeyPressed] = useState([]); @@ -89,7 +91,7 @@ export const HandyShortcut: React.FC = ({ .catch(console.error); } catch (error) { console.error("Failed to restore original binding:", error); - toast.error("Failed to restore original shortcut"); + toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { await commands.resumeBinding(editingShortcutId).catch(console.error); @@ -141,7 +143,7 @@ export const HandyShortcut: React.FC = ({ .catch(console.error); } catch (error) { console.error("Failed to change binding:", error); - toast.error(`Failed to set shortcut: ${error}`); + toast.error(t("settings.general.shortcut.errors.set", { error: String(error) })); // Reset to original binding on error if (originalBinding) { @@ -152,7 +154,7 @@ export const HandyShortcut: React.FC = ({ .catch(console.error); } catch (resetError) { console.error("Failed to reset binding:", resetError); - toast.error("Failed to reset shortcut to original value"); + toast.error(t("settings.general.shortcut.errors.reset")); } } } @@ -180,7 +182,7 @@ export const HandyShortcut: React.FC = ({ .catch(console.error); } catch (error) { console.error("Failed to restore original binding:", error); - toast.error("Failed to restore original shortcut"); + toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { commands.resumeBinding(editingShortcutId).catch(console.error); @@ -228,7 +230,7 @@ export const HandyShortcut: React.FC = ({ // Format the current shortcut keys being recorded const formatCurrentKeys = (): string => { - if (recordedKeys.length === 0) return "Press keys..."; + if (recordedKeys.length === 0) return t("settings.general.shortcut.pressKeys"); // Use the same formatting as the display to ensure consistency return formatKeyCombination(recordedKeys.join("+"), osType); @@ -243,12 +245,12 @@ export const HandyShortcut: React.FC = ({ if (isLoading) { return ( -
Loading shortcuts...
+
{t("settings.general.shortcut.loading")}
); } @@ -257,12 +259,12 @@ export const HandyShortcut: React.FC = ({ if (Object.keys(bindings).length === 0) { return ( -
No shortcuts configured
+
{t("settings.general.shortcut.none")}
); } @@ -271,20 +273,24 @@ export const HandyShortcut: React.FC = ({ if (!binding) { return ( -
No shortcut configured
+
{t("settings.general.shortcut.none")}
); } + // 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 ( = ({ descriptionMode = "inline", grouped = false, }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const historyLimit = Number(getSetting("history_limit") ?? "5"); @@ -25,8 +27,8 @@ export const HistoryLimit: React.FC = ({ return ( = ({ disabled={isUpdating("history_limit")} className="w-20" /> - entries + {t("settings.debug.historyLimit.entries")}
); diff --git a/src/components/settings/LanguageSelector.tsx b/src/components/settings/LanguageSelector.tsx index bb20ecab..a8cf3fe3 100644 --- a/src/components/settings/LanguageSelector.tsx +++ b/src/components/settings/LanguageSelector.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; @@ -17,6 +18,7 @@ export const LanguageSelector: React.FC = ({ descriptionMode = "tooltip", grouped = false, }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings(); const { currentModel, loadCurrentModel } = useModels(); const [isOpen, setIsOpen] = useState(false); @@ -70,9 +72,9 @@ export const LanguageSelector: React.FC = ({ ); const selectedLanguageName = isUnsupported - ? "Auto" + ? t("settings.general.language.auto") : LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label || - "Auto"; + t("settings.general.language.auto"); const handleLanguageSelect = async (languageCode: string) => { await updateSetting("selected_language", languageCode); @@ -105,11 +107,11 @@ export const LanguageSelector: React.FC = ({ return ( = ({ value={searchQuery} onChange={handleSearchChange} onKeyDown={handleKeyDown} - placeholder="Search languages..." + placeholder={t("settings.general.language.searchPlaceholder")} className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded focus:outline-none focus:ring-1 focus:ring-logo-primary focus:border-logo-primary" /> @@ -163,7 +165,7 @@ export const LanguageSelector: React.FC = ({
{filteredLanguages.length === 0 ? (
- No languages found + {t("settings.general.language.noResults")}
) : ( filteredLanguages.map((language) => ( diff --git a/src/components/settings/MicrophoneSelector.tsx b/src/components/settings/MicrophoneSelector.tsx index 7626d6c1..0a5f4aca 100644 --- a/src/components/settings/MicrophoneSelector.tsx +++ b/src/components/settings/MicrophoneSelector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; @@ -11,6 +12,7 @@ interface MicrophoneSelectorProps { export const MicrophoneSelector: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, @@ -41,8 +43,8 @@ export const MicrophoneSelector: React.FC = React.memo( return ( @@ -53,8 +55,8 @@ export const MicrophoneSelector: React.FC = React.memo( onSelect={handleMicrophoneSelect} placeholder={ isLoading || audioDevices.length === 0 - ? "Loading..." - : "Select microphone..." + ? t("settings.sound.microphone.loading") + : t("settings.sound.microphone.placeholder") } disabled={ isUpdating("selected_microphone") || diff --git a/src/components/settings/ModelUnloadTimeout.tsx b/src/components/settings/ModelUnloadTimeout.tsx index dd992bf7..89e3d36e 100644 --- a/src/components/settings/ModelUnloadTimeout.tsx +++ b/src/components/settings/ModelUnloadTimeout.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useSettings } from "../../hooks/useSettings"; import { commands, type ModelUnloadTimeout } from "@/bindings"; import { Dropdown } from "../ui/Dropdown"; @@ -9,27 +10,28 @@ interface ModelUnloadTimeoutProps { grouped?: boolean; } -const timeoutOptions = [ - { value: "never" as ModelUnloadTimeout, label: "Never" }, - { value: "immediately" as ModelUnloadTimeout, label: "Immediately" }, - { value: "min2" as ModelUnloadTimeout, label: "After 2 minutes" }, - { value: "min5" as ModelUnloadTimeout, label: "After 5 minutes" }, - { value: "min10" as ModelUnloadTimeout, label: "After 10 minutes" }, - { value: "min15" as ModelUnloadTimeout, label: "After 15 minutes" }, - { value: "hour1" as ModelUnloadTimeout, label: "After 1 hour" }, -]; - -const debugTimeoutOptions = [ - ...timeoutOptions, - { value: "sec5" as ModelUnloadTimeout, label: "After 5 seconds (Debug)" }, -]; - export const ModelUnloadTimeoutSetting: React.FC = ({ descriptionMode = "inline", grouped = false, }) => { + const { t } = useTranslation(); const { settings, getSetting, updateSetting } = useSettings(); + const timeoutOptions = [ + { value: "never" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.never") }, + { value: "immediately" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.immediately") }, + { value: "min2" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min2") }, + { value: "min5" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min5") }, + { value: "min10" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min10") }, + { value: "min15" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min15") }, + { value: "hour1" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.hour1") }, + ]; + + const debugTimeoutOptions = [ + ...timeoutOptions, + { value: "sec5" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.sec5") }, + ]; + const handleChange = async (event: React.ChangeEvent) => { const newTimeout = event.target.value as ModelUnloadTimeout; @@ -49,8 +51,8 @@ export const ModelUnloadTimeoutSetting: React.FC = ({ return ( diff --git a/src/components/settings/MuteWhileRecording.tsx b/src/components/settings/MuteWhileRecording.tsx index a27b01a0..b3e81542 100644 --- a/src/components/settings/MuteWhileRecording.tsx +++ b/src/components/settings/MuteWhileRecording.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -9,6 +10,7 @@ interface MuteWhileRecordingToggleProps { export const MuteWhileRecording: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const muteEnabled = getSetting("mute_while_recording") ?? false; @@ -18,8 +20,8 @@ export const MuteWhileRecording: React.FC = checked={muteEnabled} onChange={(enabled) => updateSetting("mute_while_recording", enabled)} isUpdating={isUpdating("mute_while_recording")} - label="Mute While Recording" - description="Automatically mute all sound output while Handy is recording, then restore it when finished." + label={t("settings.debug.muteWhileRecording.label")} + description={t("settings.debug.muteWhileRecording.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/OutputDeviceSelector.tsx b/src/components/settings/OutputDeviceSelector.tsx index ec45ee2a..3df632a8 100644 --- a/src/components/settings/OutputDeviceSelector.tsx +++ b/src/components/settings/OutputDeviceSelector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; @@ -14,6 +15,7 @@ interface OutputDeviceSelectorProps { export const OutputDeviceSelector: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false, disabled = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, @@ -44,8 +46,8 @@ export const OutputDeviceSelector: React.FC = return ( = onSelect={handleOutputDeviceSelect} placeholder={ isLoading || outputDevices.length === 0 - ? "Loading..." - : "Select output device..." + ? t("settings.sound.outputDevice.loading") + : t("settings.sound.outputDevice.placeholder") } disabled={ disabled || diff --git a/src/components/settings/PasteMethod.tsx b/src/components/settings/PasteMethod.tsx index 40da9919..5be870f1 100644 --- a/src/components/settings/PasteMethod.tsx +++ b/src/components/settings/PasteMethod.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { type as getOsType } from "@tauri-apps/plugin-os"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; @@ -10,31 +11,32 @@ interface PasteMethodProps { grouped?: boolean; } -const getPasteMethodOptions = (osType: string) => { - const mod = osType === "macos" ? "Cmd" : "Ctrl"; - - const options = [ - { value: "ctrl_v", label: `Clipboard (${mod}+V)` }, - { value: "direct", label: "Direct" }, - { value: "none", label: "None" }, - ]; - - // Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only - if (osType === "windows" || osType === "linux") { - options.push( - { value: "ctrl_shift_v", label: "Clipboard (Ctrl+Shift+V)" }, - { value: "shift_insert", label: "Clipboard (Shift+Insert)" }, - ); - } - - return options; -}; - export const PasteMethodSetting: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const [osType, setOsType] = useState("unknown"); + const getPasteMethodOptions = (osType: string) => { + const mod = osType === "macos" ? "Cmd" : "Ctrl"; + + const options = [ + { value: "ctrl_v", label: t("settings.advanced.pasteMethod.options.clipboard", { modifier: mod }) }, + { value: "direct", label: t("settings.advanced.pasteMethod.options.direct") }, + { value: "none", label: t("settings.advanced.pasteMethod.options.none") }, + ]; + + // Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only + if (osType === "windows" || osType === "linux") { + options.push( + { value: "ctrl_shift_v", label: t("settings.advanced.pasteMethod.options.clipboardCtrlShiftV") }, + { value: "shift_insert", label: t("settings.advanced.pasteMethod.options.clipboardShiftInsert") }, + ); + } + + return options; + }; + useEffect(() => { setOsType(getOsType()); }, []); @@ -46,8 +48,8 @@ export const PasteMethodSetting: React.FC = React.memo( return ( = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("post_process_enabled") || false; @@ -18,8 +20,8 @@ export const PostProcessingToggle: React.FC = checked={enabled} onChange={(enabled) => updateSetting("post_process_enabled", enabled)} isUpdating={isUpdating("post_process_enabled")} - label="Post Process" - description="Enable post-processing of transcribed text using language models via OpenAI Compatible API." + label={t("settings.debug.postProcessingToggle.label")} + description={t("settings.debug.postProcessingToggle.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/PushToTalk.tsx b/src/components/settings/PushToTalk.tsx index 947eb673..333d26d2 100644 --- a/src/components/settings/PushToTalk.tsx +++ b/src/components/settings/PushToTalk.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -9,6 +10,7 @@ interface PushToTalkProps { export const PushToTalk: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const pttEnabled = getSetting("push_to_talk") || false; @@ -18,8 +20,8 @@ export const PushToTalk: React.FC = React.memo( checked={pttEnabled} onChange={(enabled) => updateSetting("push_to_talk", enabled)} isUpdating={isUpdating("push_to_talk")} - label="Push To Talk" - description="Hold to record, release to stop" + label={t("settings.general.pushToTalk.label")} + description={t("settings.general.pushToTalk.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/RecordingRetentionPeriod.tsx b/src/components/settings/RecordingRetentionPeriod.tsx index 819472e1..c6ff4397 100644 --- a/src/components/settings/RecordingRetentionPeriod.tsx +++ b/src/components/settings/RecordingRetentionPeriod.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; @@ -11,6 +12,7 @@ interface RecordingRetentionPeriodProps { export const RecordingRetentionPeriodSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const selectedRetentionPeriod = @@ -25,17 +27,17 @@ export const RecordingRetentionPeriodSelector: React.FC @@ -43,7 +45,7 @@ export const RecordingRetentionPeriodSelector: React.FC diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx index f45776dc..526aeff8 100644 --- a/src/components/settings/ShowOverlay.tsx +++ b/src/components/settings/ShowOverlay.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; @@ -9,23 +10,24 @@ interface ShowOverlayProps { grouped?: boolean; } -const overlayOptions = [ - { value: "none", label: "None" }, - { value: "bottom", label: "Bottom" }, - { value: "top", label: "Top" }, -]; - export const ShowOverlay: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); + const overlayOptions = [ + { value: "none", label: t("settings.advanced.overlay.options.none") }, + { value: "bottom", label: t("settings.advanced.overlay.options.bottom") }, + { value: "top", label: t("settings.advanced.overlay.options.top") }, + ]; + const selectedPosition = (getSetting("overlay_position") || "bottom") as OverlayPosition; return ( diff --git a/src/components/settings/StartHidden.tsx b/src/components/settings/StartHidden.tsx index 348e37bd..d06762b3 100644 --- a/src/components/settings/StartHidden.tsx +++ b/src/components/settings/StartHidden.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -9,6 +10,7 @@ interface StartHiddenProps { export const StartHidden: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const startHidden = getSetting("start_hidden") ?? false; @@ -18,8 +20,8 @@ export const StartHidden: React.FC = React.memo( checked={startHidden} onChange={(enabled) => updateSetting("start_hidden", enabled)} isUpdating={isUpdating("start_hidden")} - label="Start Hidden" - description="Launch to system tray without opening the window." + label={t("settings.advanced.startHidden.label")} + description={t("settings.advanced.startHidden.description")} descriptionMode={descriptionMode} grouped={grouped} tooltipPosition="bottom" diff --git a/src/components/settings/TranslateToEnglish.tsx b/src/components/settings/TranslateToEnglish.tsx index a03abf9b..7321423b 100644 --- a/src/components/settings/TranslateToEnglish.tsx +++ b/src/components/settings/TranslateToEnglish.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -17,6 +18,7 @@ const unsupportedTranslationModels = [ export const TranslateToEnglish: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const { currentModel, loadCurrentModel, models } = useModels(); @@ -29,11 +31,13 @@ export const TranslateToEnglish: React.FC = React.memo( const currentModelDisplayName = models.find( (model) => model.id === currentModel, )?.name; - return `Translation is not supported by the ${currentModelDisplayName} model.`; + return t("settings.advanced.translateToEnglish.descriptionUnsupported", { + model: currentModelDisplayName, + }); } - return "Automatically translate speech from other languages to English during transcription."; - }, [models, currentModel, isDisabledTranslation]); + return t("settings.advanced.translateToEnglish.description"); + }, [t, models, currentModel, isDisabledTranslation]); // Listen for model state changes to update UI reactively useEffect(() => { @@ -52,7 +56,7 @@ export const TranslateToEnglish: React.FC = React.memo( onChange={(enabled) => updateSetting("translate_to_english", enabled)} isUpdating={isUpdating("translate_to_english")} disabled={isDisabledTranslation} - label="Translate to English" + label={t("settings.advanced.translateToEnglish.label")} description={description} descriptionMode={descriptionMode} grouped={grouped} diff --git a/src/components/settings/UpdateChecksToggle.tsx b/src/components/settings/UpdateChecksToggle.tsx index cf8f8ecc..675dc3a4 100644 --- a/src/components/settings/UpdateChecksToggle.tsx +++ b/src/components/settings/UpdateChecksToggle.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; @@ -11,6 +12,7 @@ export const UpdateChecksToggle: React.FC = ({ descriptionMode = "tooltip", grouped = false, }) => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const updateChecksEnabled = getSetting("update_checks_enabled") ?? true; @@ -19,8 +21,8 @@ export const UpdateChecksToggle: React.FC = ({ checked={updateChecksEnabled} onChange={(enabled) => updateSetting("update_checks_enabled", enabled)} isUpdating={isUpdating("update_checks_enabled")} - label="Check for Updates" - description="Allow Handy to automatically check for updates and enable manual checks from the footer or tray menu." + label={t("settings.debug.updateChecks.label")} + description={t("settings.debug.updateChecks.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/VolumeSlider.tsx b/src/components/settings/VolumeSlider.tsx index 7a2cd3e2..362627ad 100644 --- a/src/components/settings/VolumeSlider.tsx +++ b/src/components/settings/VolumeSlider.tsx @@ -1,10 +1,12 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { Slider } from "../ui/Slider"; import { useSettings } from "../../hooks/useSettings"; export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({ disabled = false, }) => { + const { t } = useTranslation(); const { getSetting, updateSetting } = useSettings(); const audioFeedbackVolume = getSetting("audio_feedback_volume") ?? 0.5; @@ -17,8 +19,8 @@ export const VolumeSlider: React.FC<{ disabled?: boolean }> = ({ min={0} max={1} step={0.1} - label="Volume" - description="Adjust the volume of audio feedback sounds" + label={t("settings.sound.volume.title")} + description={t("settings.sound.volume.description")} descriptionMode="tooltip" grouped formatValue={(value) => `${Math.round(value * 100)}%`} diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index a13620d0..e34d4df6 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { getVersion } from "@tauri-apps/api/app"; import { openUrl } from "@tauri-apps/plugin-opener"; import { SettingsGroup } from "../../ui/SettingsGroup"; @@ -8,6 +9,7 @@ import { AppDataDirectory } from "../AppDataDirectory"; import { AppLanguageSelector } from "../AppLanguageSelector"; export const AboutSettings: React.FC = () => { + const { t } = useTranslation(); const [version, setVersion] = useState(""); useEffect(() => { @@ -34,11 +36,11 @@ export const AboutSettings: React.FC = () => { return (
- + v{version} @@ -47,8 +49,8 @@ export const AboutSettings: React.FC = () => { - +
- Handy uses Whisper.cpp for fast, local speech-to-text processing. - Thanks to the amazing work by Georgi Gerganov and contributors. + {t("settings.about.acknowledgments.whisper.details")}
diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index b1fbb9bb..7f3bd3c6 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { ShowOverlay } from "../ShowOverlay"; import { TranslateToEnglish } from "../TranslateToEnglish"; import { ModelUnloadTimeoutSetting } from "../ModelUnloadTimeout"; @@ -10,9 +11,10 @@ import { PasteMethodSetting } from "../PasteMethod"; import { ClipboardHandlingSetting } from "../ClipboardHandling"; export const AdvancedSettings: React.FC = () => { + const { t } = useTranslation(); return (
- + diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index e81a2bb8..4914010e 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { type } from "@tauri-apps/plugin-os"; import { WordCorrectionThreshold } from "./WordCorrectionThreshold"; import { LogDirectory } from "./LogDirectory"; @@ -17,19 +18,20 @@ import { UpdateChecksToggle } from "../UpdateChecksToggle"; import { useSettings } from "../../../hooks/useSettings"; export const DebugSettings: React.FC = () => { + const { t } = useTranslation(); const { getSetting } = useSettings(); const pushToTalk = getSetting("push_to_talk"); const isLinux = type() === "linux"; return (
- + diff --git a/src/components/settings/debug/LogDirectory.tsx b/src/components/settings/debug/LogDirectory.tsx index c07f8a92..ff59b4b7 100644 --- a/src/components/settings/debug/LogDirectory.tsx +++ b/src/components/settings/debug/LogDirectory.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { commands } from "@/bindings"; import { SettingContainer } from "../../ui/SettingContainer"; import { Button } from "../../ui/Button"; @@ -12,6 +13,7 @@ export const LogDirectory: React.FC = ({ descriptionMode = "tooltip", grouped = false, }) => { + const { t } = useTranslation(); const [logDir, setLogDir] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -50,8 +52,8 @@ export const LogDirectory: React.FC = ({ return ( = ({ disabled={!logDir} className="px-3 py-2" > - Open + {t("common.open")}
)} diff --git a/src/components/settings/debug/LogLevelSelector.tsx b/src/components/settings/debug/LogLevelSelector.tsx index b3f3e5d2..2fdee0bd 100644 --- a/src/components/settings/debug/LogLevelSelector.tsx +++ b/src/components/settings/debug/LogLevelSelector.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import { SettingContainer } from "../../ui/SettingContainer"; import { Dropdown, type DropdownOption } from "../../ui/Dropdown"; import { useSettings } from "../../../hooks/useSettings"; @@ -21,6 +22,7 @@ export const LogLevelSelector: React.FC = ({ descriptionMode = "tooltip", grouped = false, }) => { + const { t } = useTranslation(); const { settings, updateSetting, isUpdating } = useSettings(); const currentLevel = settings?.log_level ?? "debug"; @@ -36,8 +38,8 @@ export const LogLevelSelector: React.FC = ({ return ( = ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); const { settings, updateSetting } = useSettings(); const handleThresholdChange = (value: number) => { @@ -22,8 +24,8 @@ export const WordCorrectionThreshold: React.FC< onChange={handleThresholdChange} min={0.0} max={1.0} - label="Word Correction Threshold" - description="Controls how aggressively custom words are applied. Lower values mean fewer corrections will be made, higher values mean more corrections. Range: 0 (least aggressive) to 1 (most aggressive)." + label={t("settings.debug.wordCorrectionThreshold.title")} + description={t("settings.debug.wordCorrectionThreshold.description")} descriptionMode={descriptionMode} grouped={grouped} /> diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index 3835ea69..c6ff8517 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -1,31 +1,36 @@ import React, { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { AudioPlayer } from "../../ui/AudioPlayer"; import { Button } from "../../ui/Button"; import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { commands, type HistoryEntry } from "@/bindings"; +import { formatDateTime } from "@/utils/dateFormat"; interface OpenRecordingsButtonProps { onClick: () => void; + label: string; } const OpenRecordingsButton: React.FC = ({ onClick, + label, }) => ( ); export const HistorySettings: React.FC = () => { + const { t } = useTranslation(); const [historyEntries, setHistoryEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -121,14 +126,14 @@ export const HistorySettings: React.FC = () => {

- History + {t("settings.history.title")}

- +
- Loading history... + {t("settings.history.loading")}
@@ -143,14 +148,14 @@ export const HistorySettings: React.FC = () => {

- History + {t("settings.history.title")}

- +
- No transcriptions yet. Start recording to build your history! + {t("settings.history.empty")}
@@ -164,10 +169,10 @@ export const HistorySettings: React.FC = () => {

- History + {t("settings.history.title")}

- +
@@ -203,6 +208,7 @@ const HistoryEntryComponent: React.FC = ({ getAudioUrl, deleteAudio, }) => { + const { t, i18n } = useTranslation(); const [audioUrl, setAudioUrl] = useState(null); const [showCopied, setShowCopied] = useState(false); @@ -229,15 +235,17 @@ const HistoryEntryComponent: React.FC = ({ } }; + const formattedDate = formatDateTime(entry.timestamp, i18n.language); + return (
-

{entry.title}

+

{formattedDate}

diff --git a/src/components/settings/post-processing/PostProcessingSettings.tsx b/src/components/settings/post-processing/PostProcessingSettings.tsx index e697266c..f3ed880e 100644 --- a/src/components/settings/post-processing/PostProcessingSettings.tsx +++ b/src/components/settings/post-processing/PostProcessingSettings.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { RefreshCcw } from "lucide-react"; import { commands } from "@/bindings"; @@ -26,13 +27,13 @@ const DisabledNotice: React.FC<{ children: React.ReactNode }> = ({ ); const PostProcessingSettingsApiComponent: React.FC = () => { + const { t } = useTranslation(); const state = usePostProcessProviderState(); if (!state.enabled) { return ( - Post processing is currently disabled. Enable it in Debug settings to - configure. + {t("settings.postProcessing.disabledNotice")} ); } @@ -40,8 +41,8 @@ const PostProcessingSettingsApiComponent: React.FC = () => { return ( <> { {state.isAppleProvider ? ( - Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later. - Apple Intelligence must be enabled in System Settings. + {t("settings.postProcessing.api.appleIntelligence.requirements")} ) : ( <> { { { @@ -112,13 +112,13 @@ const PostProcessingSettingsApiComponent: React.FC = () => { )} { isLoading={state.isFetchingModels} placeholder={ state.isAppleProvider - ? "Apple Intelligence" + ? t("settings.postProcessing.api.model.placeholderApple") : state.modelOptions.length > 0 - ? "Search or select a model" - : "Type a model name" + ? t("settings.postProcessing.api.model.placeholderWithOptions") + : t("settings.postProcessing.api.model.placeholderNoOptions") } onSelect={state.handleModelSelect} onCreate={state.handleModelCreate} @@ -145,7 +145,7 @@ const PostProcessingSettingsApiComponent: React.FC = () => { { }; const PostProcessingSettingsPromptsComponent: React.FC = () => { + const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating, refreshSettings } = useSettings(); const [isCreating, setIsCreating] = useState(false); @@ -259,8 +260,7 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { if (!enabled) { return ( - Post processing is currently disabled. Enable it in Debug settings to - configure. + {t("settings.postProcessing.disabledNotice")} ); } @@ -273,8 +273,8 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { return ( { }))} onSelect={(value) => handlePromptSelect(value)} placeholder={ - prompts.length === 0 ? "No prompts available" : "Select a prompt" + prompts.length === 0 + ? t("settings.postProcessing.prompts.noPrompts") + : t("settings.postProcessing.prompts.selectPrompt") } disabled={ isUpdating("post_process_selected_prompt_id") || isCreating @@ -302,39 +304,40 @@ const PostProcessingSettingsPromptsComponent: React.FC = () => { size="md" disabled={isCreating} > - Create New Prompt + {t("settings.postProcessing.prompts.createNew")}
{!isCreating && hasPrompts && selectedPrompt && (
- + setDraftName(e.target.value)} - placeholder="Enter prompt name" + placeholder={t("settings.postProcessing.prompts.promptLabelPlaceholder")} variant="compact" />