diff --git a/scripts/find-missing-i18n-key.js b/scripts/find-missing-i18n-key.js new file mode 100644 index 000000000..bff64f97b --- /dev/null +++ b/scripts/find-missing-i18n-key.js @@ -0,0 +1,306 @@ +/** + * Script to find missing i18n keys in Jan components + * + * Usage: + * node scripts/find-missing-i18n-key.js [options] + * + * Options: + * --locale= Only check a specific locale (e.g. --locale=id) + * --file= Only check a specific file (e.g. --file=common.json) + * --help Show this help message + */ + +const fs = require("fs") +const path = require("path") + +// Parse command-line arguments +const args = process.argv.slice(2).reduce((acc, arg) => { + if (arg === "--help") { + acc.help = true + } else if (arg.startsWith("--locale=")) { + acc.locale = arg.split("=")[1] + } else if (arg.startsWith("--file=")) { + acc.file = arg.split("=")[1] + } + return acc +}, {}) + +// Display help information +if (args.help) { + console.log(` +Find missing i18n translations in Jan + +A useful script to identify whether the i18n keys used in component files exist in all language files. + +Usage: + node scripts/find-missing-i18n-key.js [options] + +Options: + --locale= Only check a specific language (e.g., --locale=id) + --file= Only check a specific file (e.g., --file=common.json) + --help Display help information + +Output: + - Generate a report of missing translations + `) + process.exit(0) +} + +// Directories to traverse and their corresponding locales +const DIRS = { + components: { + path: path.join(__dirname, "../web-app/src/components"), + localesDir: path.join(__dirname, "../web-app/src/locales"), + }, + containers: { + path: path.join(__dirname, "../web-app/src/containers"), + localesDir: path.join(__dirname, "../web-app/src/locales"), + }, + routes: { + path: path.join(__dirname, "../web-app/src/routes"), + localesDir: path.join(__dirname, "../web-app/src/locales"), + }, +} + +// Regular expressions to match i18n keys +const i18nPatterns = [ + /{t\("([^"]+)"\)}/g, // Match {t("key")} format + /i18nKey="([^"]+)"/g, // Match i18nKey="key" format + /\bt\(\s*["']([^"']+)["']\s*(?:,\s*[^)]+)?\)/g, // Match t("key") format with optional parameters - simplified and more robust +] + +// Get all language directories for a specific locales directory +function getLocaleDirs(localesDir) { + try { + const allLocales = fs.readdirSync(localesDir).filter((file) => { + const stats = fs.statSync(path.join(localesDir, file)) + return stats.isDirectory() // Do not exclude any language directories + }) + + // Filter to a specific language if specified + return args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales + } catch (error) { + if (error.code === "ENOENT") { + console.warn(`Warning: Locales directory not found: ${localesDir}`) + return [] + } + throw error + } +} + +// Get the value from JSON by path +function getValueByPath(obj, path) { + const parts = path.split(".") + let current = obj + + for (const part of parts) { + if (current === undefined || current === null) { + return undefined + } + current = current[part] + } + + return current +} + +// Check if the key exists in all language files, return a list of missing language files +function checkKeyInLocales(key, localeDirs, localesDir) { + // Handle namespace:key format (e.g., "common:save" or "settings:general") + let namespace, keyPath + + if (key.includes(":")) { + [namespace, keyPath] = key.split(":", 2) + } else if (key.includes(".")) { + // Handle namespace.key format + const parts = key.split(".") + + // Check if the first part is a known namespace + const knownNamespaces = ['common', 'settings', 'systemMonitor', 'chat', 'hub', 'providers', 'assistants', 'mcpServers', 'mcp-servers', 'toolApproval', 'tool-approval', 'updater', 'setup', 'logs', 'provider'] + + if (knownNamespaces.includes(parts[0])) { + namespace = parts[0] + keyPath = parts.slice(1).join(".") + } else { + // Default to common namespace if no known namespace is found + namespace = "common" + keyPath = key + } + } else { + // No dots, default to common namespace + namespace = "common" + keyPath = key + } + + const missingLocales = [] + + // Map namespace to actual filename + const namespaceToFile = { + 'systemMonitor': 'system-monitor', + 'mcpServers': 'mcp-servers', + 'mcp-servers': 'mcp-servers', + 'toolApproval': 'tool-approval', + 'tool-approval': 'tool-approval' + } + + const fileName = namespaceToFile[namespace] || namespace + + localeDirs.forEach((locale) => { + const filePath = path.join(localesDir, locale, `${fileName}.json`) + if (!fs.existsSync(filePath)) { + missingLocales.push(`${locale}/${fileName}.json`) + return + } + + try { + const json = JSON.parse(fs.readFileSync(filePath, "utf8")) + + // Jan's localization files have flat structure + // e.g., common.json has { "save": "Save", "cancel": "Cancel" } + // not nested like { "common": { "save": "Save" } } + const valueToCheck = getValueByPath(json, keyPath) + + if (valueToCheck === undefined) { + missingLocales.push(`${locale}/${fileName}.json`) + } + } catch (error) { + console.warn(`Warning: Could not parse ${filePath}: ${error.message}`) + missingLocales.push(`${locale}/${fileName}.json`) + } + }) + + return missingLocales +} + +// Recursively traverse the directory +function findMissingI18nKeys() { + const results = [] + + function walk(dir, baseDir, localeDirs, localesDir) { + if (!fs.existsSync(dir)) { + console.warn(`Warning: Directory not found: ${dir}`) + return + } + + const files = fs.readdirSync(dir) + + for (const file of files) { + const filePath = path.join(dir, file) + const stat = fs.statSync(filePath) + + // Exclude test files, __mocks__ directory, and node_modules + if (filePath.includes(".test.") || + filePath.includes("__mocks__") || + filePath.includes("node_modules") || + filePath.includes(".spec.")) { + continue + } + + if (stat.isDirectory()) { + walk(filePath, baseDir, localeDirs, localesDir) // Recursively traverse subdirectories + } else if (stat.isFile() && [".ts", ".tsx", ".js", ".jsx"].includes(path.extname(filePath))) { + const content = fs.readFileSync(filePath, "utf8") + + // Match all i18n keys + for (const pattern of i18nPatterns) { + let match + while ((match = pattern.exec(content)) !== null) { + const key = match[1] + + // Skip empty keys or keys that look like variables/invalid + if (!key || + key.includes("${") || + key.includes("{{") || + key.startsWith("$") || + key.length < 2 || + key === "." || + key === "," || + key === "-" || + !/^[a-zA-Z]/.test(key)) { + continue + } + + const missingLocales = checkKeyInLocales(key, localeDirs, localesDir) + if (missingLocales.length > 0) { + results.push({ + key, + missingLocales, + file: path.relative(baseDir, filePath), + }) + } + } + } + } + } + } + + // Walk through all directories + Object.entries(DIRS).forEach(([name, config]) => { + const localeDirs = getLocaleDirs(config.localesDir) + if (localeDirs.length > 0) { + console.log(`\nChecking ${name} directory with ${localeDirs.length} languages: ${localeDirs.join(", ")}`) + walk(config.path, config.path, localeDirs, config.localesDir) + } + }) + + return results +} + +// Execute and output the results +function main() { + try { + if (args.locale) { + // Check if the specified locale exists in the locales directory + const localesDir = path.join(__dirname, "../web-app/src/locales") + const localeDirs = getLocaleDirs(localesDir) + + if (!localeDirs.includes(args.locale)) { + console.error(`Error: Language '${args.locale}' not found in ${localesDir}`) + process.exit(1) + } + } + + const missingKeys = findMissingI18nKeys() + + if (missingKeys.length === 0) { + console.log("\n✅ All i18n keys are present!") + return + } + + console.log("\nMissing i18n keys:\n") + + // Group by file for better readability + const groupedByFile = {} + missingKeys.forEach(({ key, missingLocales, file }) => { + if (!groupedByFile[file]) { + groupedByFile[file] = [] + } + groupedByFile[file].push({ key, missingLocales }) + }) + + Object.entries(groupedByFile).forEach(([file, keys]) => { + console.log(`📁 File: ${file}`) + keys.forEach(({ key, missingLocales }) => { + console.log(` 🔑 Key: ${key}`) + console.log(" ❌ Missing in:") + missingLocales.forEach((locale) => console.log(` - ${locale}`)) + console.log("") + }) + console.log("-------------------") + }) + + console.log("\n💡 To fix missing translations:") + console.log("1. Add the missing keys to the appropriate locale files") + console.log("2. Use yq commands for efficient updates:") + console.log(" yq -i '.namespace.key = \"Translation\"' web-app/src/locales//.json") + console.log("3. Run this script again to verify all keys are present") + + // Exit code 1 indicates missing keys + process.exit(1) + } catch (error) { + console.error("Error:", error.message) + console.error(error.stack) + process.exit(1) + } +} + +main() \ No newline at end of file diff --git a/scripts/find-missing-translations.js b/scripts/find-missing-translations.js new file mode 100644 index 000000000..5e307f250 --- /dev/null +++ b/scripts/find-missing-translations.js @@ -0,0 +1,253 @@ +/** + * Script to find missing translations in locale files for Jan + * + * Usage: + * node scripts/find-missing-translations.js [options] + * + * Options: + * --locale= Only check a specific locale (e.g. --locale=id) + * --file= Only check a specific file (e.g. --file=common.json) + * --help Show this help message + */ + +const fs = require("fs") +const path = require("path") + +// Process command line arguments +const args = process.argv.slice(2).reduce( + (acc, arg) => { + if (arg === "--help") { + acc.help = true + } else if (arg.startsWith("--locale=")) { + acc.locale = arg.split("=")[1] + } else if (arg.startsWith("--file=")) { + acc.file = arg.split("=")[1] + } + return acc + }, + {} +) + +// Show help if requested +if (args.help) { + console.log(` +Find Missing Translations for Jan + +A utility script to identify missing translations across locale files. +Compares non-English locale files to the English ones to find any missing keys. + +Usage: + node scripts/find-missing-translations.js [options] + +Options: + --locale= Only check a specific locale (e.g. --locale=id) + --file= Only check a specific file (e.g. --file=common.json) + --help Show this help message + +Output: + - Generates a report of missing translations for the web-app + `) + process.exit(0) +} + +// Path to the locales directory +const LOCALES_DIR = path.join(__dirname, "../web-app/src/locales") + +// Recursively find all keys in an object +function findKeys(obj, parentKey = "") { + let keys = [] + + for (const [key, value] of Object.entries(obj)) { + const currentKey = parentKey ? `${parentKey}.${key}` : key + + if (typeof value === "object" && value !== null) { + // If value is an object, recurse + keys = [...keys, ...findKeys(value, currentKey)] + } else { + // If value is a primitive, add the key + keys.push(currentKey) + } + } + + return keys +} + +// Get value at a dotted path in an object +function getValueAtPath(obj, path) { + const parts = path.split(".") + let current = obj + + for (const part of parts) { + if (current === undefined || current === null) { + return undefined + } + current = current[part] + } + + return current +} + +// Function to check translations +function checkTranslations() { + // Get all locale directories (or filter to the specified locale) + const allLocales = fs.readdirSync(LOCALES_DIR).filter((item) => { + const stats = fs.statSync(path.join(LOCALES_DIR, item)) + return stats.isDirectory() && item !== "en" // Exclude English as it's our source + }) + + // Filter to the specified locale if provided + const locales = args.locale ? allLocales.filter((locale) => locale === args.locale) : allLocales + + if (args.locale && locales.length === 0) { + console.error(`Error: Locale '${args.locale}' not found in ${LOCALES_DIR}`) + process.exit(1) + } + + console.log(`Checking ${locales.length} non-English locale(s): ${locales.join(", ")}`) + + // Get all English JSON files + const englishDir = path.join(LOCALES_DIR, "en") + let englishFiles = fs.readdirSync(englishDir).filter((file) => file.endsWith(".json") && !file.startsWith(".")) + + // Filter to the specified file if provided + if (args.file) { + if (!englishFiles.includes(args.file)) { + console.error(`Error: File '${args.file}' not found in ${englishDir}`) + process.exit(1) + } + englishFiles = englishFiles.filter((file) => file === args.file) + } + + // Load file contents + let englishFileContents + + try { + englishFileContents = englishFiles.map((file) => ({ + name: file, + content: JSON.parse(fs.readFileSync(path.join(englishDir, file), "utf8")), + })) + } catch (e) { + console.error(`Error: File '${englishDir}' is not a valid JSON file`) + process.exit(1) + } + + console.log( + `Checking ${englishFileContents.length} translation file(s): ${englishFileContents.map((f) => f.name).join(", ")}` + ) + + // Results object to store missing translations + const missingTranslations = {} + + // For each locale, check for missing translations + for (const locale of locales) { + missingTranslations[locale] = {} + + for (const { name, content: englishContent } of englishFileContents) { + const localeFilePath = path.join(LOCALES_DIR, locale, name) + + // Check if the file exists in the locale + if (!fs.existsSync(localeFilePath)) { + missingTranslations[locale][name] = { file: "File is missing entirely" } + continue + } + + // Load the locale file + let localeContent + + try { + localeContent = JSON.parse(fs.readFileSync(localeFilePath, "utf8")) + } catch (e) { + console.error(`Error: File '${localeFilePath}' is not a valid JSON file`) + process.exit(1) + } + + // Find all keys in the English file + const englishKeys = findKeys(englishContent) + + // Check for missing keys in the locale file + const missingKeys = [] + + for (const key of englishKeys) { + const englishValue = getValueAtPath(englishContent, key) + const localeValue = getValueAtPath(localeContent, key) + + if (localeValue === undefined) { + missingKeys.push({ + key, + englishValue, + }) + } + } + + if (missingKeys.length > 0) { + missingTranslations[locale][name] = missingKeys + } + } + } + + return outputResults(missingTranslations) +} + +// Function to output results +function outputResults(missingTranslations) { + let hasMissingTranslations = false + + console.log(`\nMissing Translations Report:\n`) + + for (const [locale, files] of Object.entries(missingTranslations)) { + if (Object.keys(files).length === 0) { + console.log(`✅ ${locale}: No missing translations`) + continue + } + + hasMissingTranslations = true + console.log(`📝 ${locale}:`) + + for (const [fileName, missingItems] of Object.entries(files)) { + if (missingItems.file) { + console.log(` - ${fileName}: ${missingItems.file}`) + continue + } + + console.log(` - ${fileName}: ${missingItems.length} missing translations`) + + for (const { key, englishValue } of missingItems) { + console.log(` ${key}: "${englishValue}"`) + } + } + + console.log("") + } + + return hasMissingTranslations +} + +// Main function to find missing translations +function findMissingTranslations() { + try { + console.log("Starting translation check for Jan web-app...") + + const hasMissingTranslations = checkTranslations() + + // Summary + if (!hasMissingTranslations) { + console.log("\n✅ All translations are complete!") + } else { + console.log("\n✏️ To add missing translations:") + console.log("1. Add the missing keys to the corresponding locale files") + console.log("2. Translate the English values to the appropriate language") + console.log("3. You can use yq commands to update JSON files efficiently:") + console.log(" yq -i '.namespace.key = \"Translation\"' web-app/src/locales//.json") + console.log("4. Run this script again to verify all translations are complete") + // Exit with error code to fail CI checks + process.exit(1) + } + } catch (error) { + console.error("Error:", error.message) + console.error(error.stack) + process.exit(1) + } +} + +// Run the main function +findMissingTranslations() \ No newline at end of file diff --git a/web-app/src/components/ui/dialog.tsx b/web-app/src/components/ui/dialog.tsx index 632150029..98f4da71c 100644 --- a/web-app/src/components/ui/dialog.tsx +++ b/web-app/src/components/ui/dialog.tsx @@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog' import { XIcon } from 'lucide-react' import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' function Dialog({ ...props @@ -58,6 +59,7 @@ function DialogContent({ 'aria-describedby': ariaDescribedBy, ...props }: DialogContentProps) { + const { t } = useTranslation() return ( @@ -74,7 +76,7 @@ function DialogContent({ {showCloseButton && ( - Close + {t('close')} )} diff --git a/web-app/src/components/ui/sheet.tsx b/web-app/src/components/ui/sheet.tsx index bc09f428d..10ba1db5d 100644 --- a/web-app/src/components/ui/sheet.tsx +++ b/web-app/src/components/ui/sheet.tsx @@ -3,6 +3,7 @@ import * as SheetPrimitive from '@radix-ui/react-dialog' import { XIcon } from 'lucide-react' import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' function Sheet({ ...props }: React.ComponentProps) { return @@ -50,6 +51,7 @@ function SheetContent({ }: React.ComponentProps & { side?: 'top' | 'right' | 'bottom' | 'left' }) { + const { t } = useTranslation() return ( @@ -72,7 +74,7 @@ function SheetContent({ {children} - Close + {t('close')} diff --git a/web-app/src/containers/ApiKeyInput.tsx b/web-app/src/containers/ApiKeyInput.tsx index 4ea717f1f..394df5696 100644 --- a/web-app/src/containers/ApiKeyInput.tsx +++ b/web-app/src/containers/ApiKeyInput.tsx @@ -1,7 +1,8 @@ import { Input } from '@/components/ui/input' import { useLocalApiServer } from '@/hooks/useLocalApiServer' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Eye, EyeOff } from 'lucide-react' +import { useTranslation } from '@/i18n/react-i18next-compat' interface ApiKeyInputProps { showError?: boolean @@ -16,23 +17,24 @@ export function ApiKeyInput({ const [inputValue, setInputValue] = useState(apiKey.toString()) const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') + const { t } = useTranslation() - const validateApiKey = (value: string) => { + const validateApiKey = useCallback((value: string) => { if (!value || value.trim().length === 0) { - setError('API Key is required') + setError(t('common:apiKeyRequired')) onValidationChange?.(false) return false } setError('') onValidationChange?.(true) return true - } + }, [onValidationChange, t]) useEffect(() => { if (showError) { validateApiKey(inputValue) } - }, [showError, inputValue]) + }, [showError, inputValue, validateApiKey]) const handleChange = (e: React.ChangeEvent) => { const value = e.target.value @@ -67,7 +69,7 @@ export function ApiKeyInput({ ? 'border-1 border-destructive focus:border-destructive focus:ring-destructive' : '' }`} - placeholder="Enter API Key" + placeholder={t('common:enterApiKey')} />
-

Vision

+

{t('vision')}

@@ -457,7 +457,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { -

Embeddings

+

{t('embeddings')}

@@ -513,7 +513,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { -

Tools

+

{t('tools')}

@@ -547,7 +547,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { -

Reasoning

+

{t('reasoning')}

diff --git a/web-app/src/containers/ChatWidthSwitcher.tsx b/web-app/src/containers/ChatWidthSwitcher.tsx index ddaf4d4fe..27cc3c69d 100644 --- a/web-app/src/containers/ChatWidthSwitcher.tsx +++ b/web-app/src/containers/ChatWidthSwitcher.tsx @@ -2,9 +2,11 @@ import { Skeleton } from '@/components/ui/skeleton' import { useAppearance } from '@/hooks/useAppearance' import { cn } from '@/lib/utils' import { IconCircleCheckFilled } from '@tabler/icons-react' +import { useTranslation } from '@/i18n/react-i18next-compat' export function ChatWidthSwitcher() { const { chatWidth, setChatWidth } = useAppearance() + const { t } = useTranslation() return (
@@ -16,7 +18,7 @@ export function ChatWidthSwitcher() { onClick={() => setChatWidth('compact')} >
- Compact Width + {t('common:compactWidth')} {chatWidth === 'compact' && ( )} @@ -27,7 +29,7 @@ export function ChatWidthSwitcher() {
- Ask me anything... + {t('common:placeholder.chatInput')}
@@ -40,7 +42,7 @@ export function ChatWidthSwitcher() { onClick={() => setChatWidth('full')} >
- Full Width + {t('common:fullWidth')} {chatWidth === 'full' && ( )} @@ -51,7 +53,7 @@ export function ChatWidthSwitcher() {
- Ask me anything... + {t('common:placeholder.chatInput')}
diff --git a/web-app/src/containers/CodeBlockExample.tsx b/web-app/src/containers/CodeBlockExample.tsx index 75b9ee3a5..47934dd44 100644 --- a/web-app/src/containers/CodeBlockExample.tsx +++ b/web-app/src/containers/CodeBlockExample.tsx @@ -1,4 +1,5 @@ import { RenderMarkdown } from './RenderMarkdown' +import { useTranslation } from '@/i18n/react-i18next-compat' const EXAMPLE_CODE = `\`\`\`typescript // Example code for preview @@ -12,10 +13,11 @@ console.log(message); // Outputs: Hello, Jan! \`\`\`` export function CodeBlockExample() { + const { t } = useTranslation() return (
- Preview + {t('preview')}
diff --git a/web-app/src/containers/ColorPickerAppBgColor.tsx b/web-app/src/containers/ColorPickerAppBgColor.tsx index 5569d2b54..eefffc272 100644 --- a/web-app/src/containers/ColorPickerAppBgColor.tsx +++ b/web-app/src/containers/ColorPickerAppBgColor.tsx @@ -7,9 +7,11 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { useTranslation } from '@/i18n/react-i18next-compat' export function ColorPickerAppBgColor() { const { appBgColor, setAppBgColor } = useAppearance() + const { t } = useTranslation() const predefineAppBgColor: RgbaColor[] = [ { @@ -80,7 +82,7 @@ export function ColorPickerAppBgColor() { @@ -282,11 +283,12 @@ const LeftPanel = () => {
-
No results found
+
+ {t('common:noResultsFound')} +

- We couldn't find any chats matching your search. Try a - different keyword. + {t('common:noResultsFoundDesc')}

)} @@ -296,10 +298,12 @@ const LeftPanel = () => {
-
No threads yet
+
+ {t('common:noThreadsYet')} +

- Start a new conversation to see your thread history here. + {t('common:noThreadsYetDesc')}

diff --git a/web-app/src/containers/ModelSetting.tsx b/web-app/src/containers/ModelSetting.tsx index 776ced4d5..bc5e810e1 100644 --- a/web-app/src/containers/ModelSetting.tsx +++ b/web-app/src/containers/ModelSetting.tsx @@ -14,6 +14,7 @@ import { useModelProvider } from '@/hooks/useModelProvider' import { updateModel, stopModel } from '@/services/models' import { ModelSettingParams } from '@janhq/core' import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' type ModelSettingProps = { provider: ProviderObject @@ -27,6 +28,7 @@ export function ModelSetting({ smallIcon, }: ModelSettingProps) { const { updateProvider } = useModelProvider() + const { t } = useTranslation() // Create a debounced version of stopModel that waits 500ms after the last call const debouncedStopModel = debounce((modelId: string) => { @@ -104,9 +106,9 @@ export function ModelSetting({ - Model Settings - {model.id} + {t('common:modelSettings.title', { modelId: model.id })} - Configure model settings to optimize performance and behavior. + {t('common:modelSettings.description')}
diff --git a/web-app/src/containers/ProvidersMenu.tsx b/web-app/src/containers/ProvidersMenu.tsx index b8e97e201..7da1d2f83 100644 --- a/web-app/src/containers/ProvidersMenu.tsx +++ b/web-app/src/containers/ProvidersMenu.tsx @@ -19,6 +19,7 @@ import { openAIProviderSettings } from '@/mock/data' import ProvidersAvatar from '@/containers/ProvidersAvatar' import cloneDeep from 'lodash/cloneDeep' import { toast } from 'sonner' +import { useTranslation } from '@/i18n/react-i18next-compat' const ProvidersMenu = ({ stepSetupRemoteProvider, @@ -29,11 +30,11 @@ const ProvidersMenu = ({ const navigate = useNavigate() const matches = useMatches() const [name, setName] = useState('') + const { t } = useTranslation() + const createProvider = useCallback(() => { if (providers.some((e) => e.provider === name)) { - toast.error( - `Provider with name "${name}" already exists. Please choose a different name.` - ) + toast.error(t('providerAlreadyExists', { name })) return } const newProvider = { @@ -53,14 +54,14 @@ const ProvidersMenu = ({ }, }) }, 0) - }, [providers, name, addProvider, navigate]) + }, [providers, name, addProvider, t, navigate]) return (
- Back + {t('common:back')}
@@ -108,17 +109,17 @@ const ProvidersMenu = ({
- Add Provider + {t('provider:addProvider')}
- Add OpenAI Provider + {t('provider:addOpenAIProvider')} setName(e.target.value)} className="mt-2" - placeholder="Enter a name for your provider" + placeholder={t('provider:enterNameForProvider')} onKeyDown={(e) => { // Prevent key from being captured by parent components e.stopPropagation() @@ -131,12 +132,12 @@ const ProvidersMenu = ({ size="sm" className="hover:no-underline" > - Cancel + {t('common:cancel')} diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index af4919878..595e02352 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -15,6 +15,7 @@ import { useCodeblock } from '@/hooks/useCodeblock' import 'katex/dist/katex.min.css' import { IconCopy, IconCopyCheck } from '@tabler/icons-react' import rehypeRaw from 'rehype-raw' +import { useTranslation } from '@/i18n/react-i18next-compat' interface MarkdownProps { content: string @@ -33,6 +34,7 @@ function RenderMarkdownComponent({ components, isWrapping, }: MarkdownProps) { + const { t } = useTranslation() const { codeBlockStyle, showLineNumbers } = useCodeblock() // State for tracking which code block has been copied @@ -91,12 +93,12 @@ function RenderMarkdownComponent({ {copiedId === codeId ? ( <> - Copied! + {t('copied')} ) : ( <> - Copy + {t('copy')} )} diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index c23ed6acf..4ab21f80b 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -1,49 +1,49 @@ import { Link, useMatches } from '@tanstack/react-router' import { route } from '@/constants/routes' -import { useTranslation } from 'react-i18next' +import { useTranslation } from '@/i18n/react-i18next-compat' import { useModelProvider } from '@/hooks/useModelProvider' import { isProd } from '@/lib/version' const menuSettings = [ { - title: 'common.general', + title: 'common:general', route: route.settings.general, }, { - title: 'common.appearance', + title: 'common:appearance', route: route.settings.appearance, }, { - title: 'common.privacy', + title: 'common:privacy', route: route.settings.privacy, }, { - title: 'common.keyboardShortcuts', + title: 'common:keyboardShortcuts', route: route.settings.shortcuts, }, { - title: 'Hardware', + title: 'common:hardware', route: route.settings.hardware, }, // Only show MCP Servers in non-production environment ...(!isProd ? [ { - title: 'MCP Servers', + title: 'common:mcp-servers', route: route.settings.mcp_servers, }, ] : []), { - title: 'Local API Server', + title: 'common:local_api_server', route: route.settings.local_api_server, }, { - title: 'HTTPS Proxy', + title: 'common:https_proxy', route: route.settings.https_proxy, }, { - title: 'Extensions', + title: 'common:extensions', route: route.settings.extensions, }, ] @@ -83,7 +83,7 @@ const SettingsMenu = () => { {/* Model Providers Link with default parameter */} {isActive ? (
- {t('common.modelProviders')} + {t('common:modelProviders')}
) : ( { className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded" > - {t('common.modelProviders')} + {t('common:modelProviders')} )} diff --git a/web-app/src/containers/SetupScreen.tsx b/web-app/src/containers/SetupScreen.tsx index cf8e32c84..807568073 100644 --- a/web-app/src/containers/SetupScreen.tsx +++ b/web-app/src/containers/SetupScreen.tsx @@ -4,8 +4,10 @@ import { Link } from '@tanstack/react-router' import { route } from '@/constants/routes' import HeaderPage from './HeaderPage' import { isProd } from '@/lib/version' +import { useTranslation } from '@/i18n/react-i18next-compat' function SetupScreen() { + const { t } = useTranslation() const { providers } = useModelProvider() const firstItemRemoteProvider = providers.length > 0 ? providers[1].provider : 'openai' @@ -17,11 +19,10 @@ function SetupScreen() {

- Welcome to Jan + {t('setup:welcome')}

- To get started, you'll need to either download a local AI model or - connect to a cloud model using an API key + {t('setup:description')}

@@ -35,7 +36,7 @@ function SetupScreen() { >

- Set up local model + {t('setup:localModel')}

@@ -53,7 +54,7 @@ function SetupScreen() { }} >

- Set up remote provider + {t('setup:remoteProvider')}

} diff --git a/web-app/src/containers/ThemeSwitcher.tsx b/web-app/src/containers/ThemeSwitcher.tsx index 2f6d5bf2c..fce342a82 100644 --- a/web-app/src/containers/ThemeSwitcher.tsx +++ b/web-app/src/containers/ThemeSwitcher.tsx @@ -6,12 +6,15 @@ import { } from '@/components/ui/dropdown-menu' import { useTheme } from '@/hooks/useTheme' import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' export function ThemeSwitcher() { + const { t } = useTranslation() + const themeOptions = [ - { value: 'dark', label: 'Dark' }, - { value: 'light', label: 'Light' }, - { value: 'auto', label: 'System' }, + { value: 'dark', label: t('common:dark') }, + { value: 'light', label: t('common:light') }, + { value: 'auto', label: t('common:system') }, ] const { setTheme, activeTheme } = useTheme() @@ -20,11 +23,11 @@ export function ThemeSwitcher() { {themeOptions.find((item) => item.value === activeTheme)?.label || - 'Auto'} + t('common:auto')} diff --git a/web-app/src/containers/ThinkingBlock.tsx b/web-app/src/containers/ThinkingBlock.tsx index c1445acf3..88ec03b52 100644 --- a/web-app/src/containers/ThinkingBlock.tsx +++ b/web-app/src/containers/ThinkingBlock.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronUp, Loader } from 'lucide-react' import { create } from 'zustand' import { RenderMarkdown } from './RenderMarkdown' import { useAppState } from '@/hooks/useAppState' +import { useTranslation } from '@/i18n/react-i18next-compat' interface Props { text: string @@ -28,6 +29,7 @@ const useThinkingStore = create((set) => ({ const ThinkingBlock = ({ id, text }: Props) => { const { thinkingState, toggleState } = useThinkingStore() const { streamingContent } = useAppState() + const { t } = useTranslation() const loading = !text.includes('') && streamingContent const isExpanded = thinkingState[id] ?? false const handleClick = () => toggleState(id) @@ -51,7 +53,7 @@ const ThinkingBlock = ({ id, text }: Props) => { )} - {loading ? 'Thinking...' : 'Thought'} + {loading ? t('common:thinking') : t('common:thought')}
diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx index 076327ea6..27bcf10d1 100644 --- a/web-app/src/containers/ThreadContent.tsx +++ b/web-app/src/containers/ThreadContent.tsx @@ -39,9 +39,11 @@ import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator' import CodeEditor from '@uiw/react-textarea-code-editor' import '@uiw/react-textarea-code-editor/dist.css' +import { useTranslation } from '@/i18n/react-i18next-compat' const CopyButton = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false) + const { t } = useTranslation() const handleCopy = () => { navigator.clipboard.writeText(text) @@ -57,7 +59,7 @@ const CopyButton = ({ text }: { text: string }) => { {copied ? ( <> - Copied! + {t('copied')} ) : ( @@ -65,7 +67,7 @@ const CopyButton = ({ text }: { text: string }) => { -

Copy

+

{t('copy')}

)} @@ -87,6 +89,7 @@ export const ThreadContent = memo( } ) => { const [message, setMessage] = useState(item.content?.[0]?.text?.value || '') + const { t } = useTranslation() // Use useMemo to stabilize the components prop const linkComponents = useMemo( @@ -226,13 +229,13 @@ export const ThreadContent = memo(
-

Edit

+

{t('edit')}

- Edit Message + {t('common:dialogs.editMessage.title')}