From f06a67e95e609307baf8e43f5970dffb98571562 Mon Sep 17 00:00:00 2001 From: Antigravity AI Date: Fri, 17 Apr 2026 21:52:07 +1200 Subject: [PATCH] feat: add full Chinese/English i18n support Implement complete internationalization using react-i18next across the entire app. All UI strings in components, modals, panels, context menus, and adjustment controls are now translated. Language selection persists via AppSettings and switches instantly without restart. - Add i18next framework with en and zh-CN locale files - Add language field to AppSettings (Rust + TypeScript) - Replace hardcoded strings in 50+ components with t() calls - Translate mask type names, color labels, waveform modes, curve menus - Support dynamic strings (mask default names, copy/inverted suffixes) Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 147 +- package.json | 2 + src-tauri/src/exif_processing.rs | 87 +- src-tauri/src/file_management.rs | 3 + src/App.tsx | 175 +-- src/components/adjustments/Basic.tsx | 34 +- src/components/adjustments/Color.tsx | 54 +- src/components/adjustments/Curves.tsx | 11 +- src/components/adjustments/Details.tsx | 22 +- src/components/adjustments/Effects.tsx | 30 +- src/components/modals/AddPresetModal.tsx | 10 +- src/components/modals/CollageModal.tsx | 28 +- src/components/modals/ConfirmModal.tsx | 10 +- .../modals/CopyPasteSettingsModal.tsx | 28 +- src/components/modals/CreateFolderModal.tsx | 10 +- src/components/modals/CullingModal.tsx | 66 +- src/components/modals/DenoiseModal.tsx | 54 +- src/components/modals/HdrModal.tsx | 31 +- src/components/modals/ImportSettingsModal.tsx | 24 +- src/components/modals/LensCorrectionModal.tsx | 58 +- .../modals/NegativeConversionModal.tsx | 34 +- src/components/modals/PanoramaModal.tsx | 31 +- src/components/modals/RenameFileModal.tsx | 10 +- src/components/modals/RenameFolderModal.tsx | 10 +- src/components/modals/RenamePresetModal.tsx | 10 +- src/components/modals/TransformModal.tsx | 44 +- src/components/panel/BottomBar.tsx | 25 +- src/components/panel/CommunityPage.tsx | 30 +- src/components/panel/Filmstrip.tsx | 4 +- src/components/panel/FolderTree.tsx | 17 +- src/components/panel/MainLibrary.tsx | 199 +-- src/components/panel/SettingsPanel.tsx | 565 ++++---- src/components/panel/editor/EditorToolbar.tsx | 184 +-- src/components/panel/editor/Waveform.tsx | 18 +- src/components/panel/right/AIPanel.tsx | 164 +-- src/components/panel/right/ControlsPanel.tsx | 20 +- src/components/panel/right/CropPanel.tsx | 109 +- src/components/panel/right/ExportPanel.tsx | 108 +- .../panel/right/LibraryExportPanel.tsx | 108 +- src/components/panel/right/Masks.tsx | 46 + src/components/panel/right/MasksPanel.tsx | 256 ++-- src/components/panel/right/MetadataPanel.tsx | 56 +- src/components/panel/right/PresetsPanel.tsx | 46 +- .../panel/right/RightPanelSwitcher.tsx | 22 +- src/components/ui/AppProperties.tsx | 1 + src/components/ui/ColorWheel.tsx | 10 +- src/components/ui/Dropdown.tsx | 10 +- src/components/ui/ExportPresetsList.tsx | 14 +- src/components/ui/ImagePicker.tsx | 12 +- src/components/ui/LUTControl.tsx | 18 +- src/context/TaggingSubMenu.tsx | 12 +- src/i18n.ts | 19 + src/locales/en/translation.json | 1195 ++++++++++++++++ src/locales/zh-CN/translation.json | 1196 +++++++++++++++++ src/main.tsx | 1 + 55 files changed, 4122 insertions(+), 1366 deletions(-) create mode 100644 src/i18n.ts create mode 100644 src/locales/en/translation.json create mode 100644 src/locales/zh-CN/translation.json diff --git a/package-lock.json b/package-lock.json index ca18425fe..8bddaf2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@uiw/react-color-wheel": "^2.9.6", "clsx": "^2.1.1", "framer-motion": "^12.35.2", + "i18next": "^26.0.5", "konva": "^10.2.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", @@ -24,6 +25,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-draggable": "^4.5.0", + "react-i18next": "^17.0.3", "react-image-crop": "^11.0.10", "react-konva": "^19.2.3", "react-toastify": "^11.0.5", @@ -59,7 +61,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -623,9 +624,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -643,9 +641,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -663,9 +658,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -683,9 +675,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -703,9 +692,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +709,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -943,9 +926,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -963,9 +943,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -983,9 +960,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1003,9 +977,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1203,9 +1174,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1223,9 +1191,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1243,9 +1208,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1263,9 +1225,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1283,9 +1242,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -3294,6 +3250,46 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4043,9 +4039,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4067,9 +4060,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4091,9 +4081,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4115,9 +4102,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4711,6 +4695,33 @@ "react-dom": ">= 16.3.0" } }, + "node_modules/react-i18next": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.3.tgz", + "integrity": "sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-image-crop": { "version": "11.0.10", "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz", @@ -5485,7 +5496,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5548,6 +5559,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -5639,6 +5659,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c386f9705..5f381eb65 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@uiw/react-color-wheel": "^2.9.6", "clsx": "^2.1.1", "framer-motion": "^12.35.2", + "i18next": "^26.0.5", "konva": "^10.2.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", @@ -31,6 +32,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-draggable": "^4.5.0", + "react-i18next": "^17.0.3", "react-image-crop": "^11.0.10", "react-konva": "^19.2.3", "react-toastify": "^11.0.5", diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 33d0c4fed..6128041d3 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -11,6 +11,58 @@ use little_exif::metadata::Metadata; use little_exif::rational::{iR64, uR64}; use rawler::decoders::RawMetadata; +/// Decode an EXIF ASCII-type field's raw bytes as UTF-8. +/// kamadak-exif's display_value() casts each byte to a Latin-1 char, which +/// corrupts UTF-8 multi-byte sequences (e.g. Chinese text appears as spaces +/// or garbage). We read the raw bytes directly and decode as UTF-8 instead. +fn decode_ascii_field(vecs: &[Vec]) -> String { + vecs.iter() + .map(|v| { + // Strip trailing null bytes + let trimmed: Vec = v.iter().copied().filter(|&b| b != 0).collect(); + // Try strict UTF-8 first; fall back to lossy for GBK / Latin-1 + String::from_utf8(trimmed.clone()) + .unwrap_or_else(|_| String::from_utf8_lossy(&trimmed).into_owned()) + }) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(", ") +} + +/// Decode EXIF UserComment field. +/// The first 8 bytes are a charset identifier: +/// b"ASCII\0\0\0" → remaining bytes are ASCII/UTF-8 +/// b"UNICODE\0" → remaining bytes are UTF-16LE +/// b"JIS\0\0\0\0\0" → JIS (decoded lossy as Latin-1 fallback) +/// [0u8; 8] → undefined, try UTF-8 lossy +fn decode_user_comment(bytes: &[u8]) -> Option { + if bytes.len() <= 8 { + return None; + } + let charset = &bytes[..8]; + let content = &bytes[8..]; + + let text = if charset.starts_with(b"UNICODE") { + // UTF-16LE + let utf16: Vec = content + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + String::from_utf16_lossy(&utf16) + .trim_matches('\0') + .trim() + .to_string() + } else { + // ASCII, JIS, undefined: treat as UTF-8 with lossy fallback + String::from_utf8_lossy(content) + .trim_matches('\0') + .trim() + .to_string() + }; + + if text.is_empty() { None } else { Some(text) } +} + fn to_ur64(val: &exif::Rational) -> uR64 { uR64 { nominator: val.num, @@ -204,10 +256,25 @@ pub fn read_exif_data(path: &str, file_bytes: &[u8]) -> HashMap let mut exif_data = HashMap::new(); if let Some(exif) = read_exif(file_bytes) { for field in exif.fields() { - exif_data.insert( - field.tag.to_string(), - field.display_value().with_unit(&exif).to_string(), - ); + let val = match &field.value { + exif::Value::Undefined(bytes, _) if field.tag == exif::Tag::UserComment => { + match decode_user_comment(bytes) { + Some(text) => text, + None => continue, + } + } + exif::Value::Ascii(vecs) => { + let s = decode_ascii_field(vecs); + if s.trim().is_empty() { + continue; + } + s + } + _ => field.display_value().with_unit(&exif).to_string(), + }; + if !val.trim().is_empty() { + exif_data.insert(field.tag.to_string(), val); + } } } exif_data @@ -297,8 +364,18 @@ pub fn extract_metadata(file_bytes: &[u8]) -> Option> { fmt_date_str(field.display_value().to_string()), ); } + exif::Tag::UserComment => { + if let exif::Value::Undefined(bytes, _) = &field.value { + if let Some(text) = decode_user_comment(bytes) { + map.insert("UserComment".to_string(), text); + } + } + } _ => { - let val = field.display_value().with_unit(&exif_obj).to_string(); + let val = match &field.value { + exif::Value::Ascii(vecs) => decode_ascii_field(vecs), + _ => field.display_value().with_unit(&exif_obj).to_string(), + }; if !val.trim().is_empty() { map.insert(field.tag.to_string(), val); } diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 94953fcdd..456609f60 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -406,6 +406,8 @@ pub struct AppSettings { pub waveform_height: Option, #[serde(default)] pub active_waveform_channel: Option, + #[serde(default)] + pub language: Option, } fn default_adjustment_visibility() -> HashMap { @@ -476,6 +478,7 @@ impl Default for AppSettings { is_waveform_visible: Some(false), waveform_height: Some(220), active_waveform_channel: Some("luma".to_string()), + language: None, } } } diff --git a/src/App.tsx b/src/App.tsx index ab1dcc547..16d4cf1cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from './i18n'; import { motion, AnimatePresence } from 'framer-motion'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -266,6 +268,7 @@ const insertChildrenIntoTree = (node: any, targetPath: string, newChildren: any[ }; function App() { + const { t } = useTranslation(); const [rootPath, setRootPath] = useState(null); const [appSettings, setAppSettings] = useState(null); const [osPlatform, setOsPlatform] = useState(() => { @@ -1780,6 +1783,9 @@ function App() { if (settings?.theme) { setTheme(settings.theme); } + if (settings?.language) { + i18n.changeLanguage(settings.language); + } if (settings?.uiVisibility) { setUiVisibility((prev) => ({ ...prev, ...settings.uiVisibility })); } @@ -1972,13 +1978,18 @@ function App() { const fontFamily = appSettings?.fontFamily || 'poppins'; const fontStack = fontFamily === 'system' - ? '-apple-system, BlinkMacSystemFont, system-ui, sans-serif' - : "'Poppins', system-ui, sans-serif"; + ? "-apple-system, BlinkMacSystemFont, system-ui, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif" + : "'Poppins', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', system-ui, sans-serif"; root.style.setProperty('--font-family', fontStack); const isLight = [Theme.Light, Theme.Snow, Theme.Arctic].includes(effectThemeForWindow); invoke(Invokes.UpdateWindowEffect, { theme: isLight ? Theme.Light : Theme.Dark }); - }, [theme, adaptivePalette, appSettings?.fontFamily]); + + const language = appSettings?.language || 'en'; + if (i18n.language !== language) { + i18n.changeLanguage(language); + } + }, [theme, adaptivePalette, appSettings?.fontFamily, appSettings?.language]); useEffect(() => { if (isInitialThemeMount.current) { @@ -2593,20 +2604,20 @@ function App() { !pathsToDelete[0].includes('?vc=') && imageList.some((image) => image.path.startsWith(`${pathsToDelete[0]}?vc=`)); - let modalTitle = 'Confirm Delete'; + let modalTitle = t('modals.delete_confirm_title'); let modalMessage = ''; - let confirmText = 'Delete'; + let confirmText = t('modals.delete_single_confirm'); if (selectionHasVirtualCopies) { - modalTitle = 'Delete Image and All Virtual Copies?'; - modalMessage = `Are you sure you want to permanently delete this image and all of its virtual copies? This action cannot be undone.`; - confirmText = 'Delete All'; + modalTitle = t('modals.delete_vc_title'); + modalMessage = t('modals.delete_vc_message'); + confirmText = t('modals.delete_vc_confirm'); } else if (isSingle) { - modalMessage = `Are you sure you want to permanently delete this image? This action cannot be undone. Right-click for more options (e.g., deleting associated files).`; - confirmText = 'Delete Selected Only'; + modalMessage = t('modals.delete_single_message'); + confirmText = t('modals.delete_single_confirm'); } else { - modalMessage = `Are you sure you want to permanently delete these ${pathsToDelete.length} images? This action cannot be undone. Right-click for more options (e.g., deleting associated files).`; - confirmText = 'Delete Selected Only'; + modalMessage = t('modals.delete_multi_message', { count: pathsToDelete.length }); + confirmText = t('modals.delete_multi_confirm'); } setConfirmModalState({ @@ -4204,7 +4215,7 @@ function App() { }, ], multiple: true, - title: 'Select files to import', + title: t('app.ctx_select_files_import'), }); if (Array.isArray(selected) && selected.length > 0) { @@ -4238,7 +4249,7 @@ function App() { const options: Array