diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 07253480..573aee8a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -136,6 +136,7 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + setLocale: (locale: string) => Promise; }; } diff --git a/electron/i18n.ts b/electron/i18n.ts new file mode 100644 index 00000000..b3850086 --- /dev/null +++ b/electron/i18n.ts @@ -0,0 +1,59 @@ +// Lightweight i18n for the Electron main process. +// Imports the same JSON translation files used by the renderer. + +import commonEn from "../src/i18n/locales/en/common.json"; +import dialogsEn from "../src/i18n/locales/en/dialogs.json"; +import commonEs from "../src/i18n/locales/es/common.json"; +import dialogsEs from "../src/i18n/locales/es/dialogs.json"; +import commonZh from "../src/i18n/locales/zh-CN/common.json"; +import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; + +type Locale = "en" | "zh-CN" | "es"; +type Namespace = "common" | "dialogs"; +type MessageMap = Record; + +const messages: Record> = { + en: { common: commonEn, dialogs: dialogsEn }, + "zh-CN": { common: commonZh, dialogs: dialogsZh }, + es: { common: commonEs, dialogs: dialogsEs }, +}; + +let currentLocale: Locale = "en"; + +export function setMainLocale(locale: string) { + if (locale === "en" || locale === "zh-CN" || locale === "es") { + currentLocale = locale; + } +} + +export function getMainLocale(): Locale { + return currentLocale; +} + +function getMessageValue(obj: unknown, dotPath: string): string | undefined { + const keys = dotPath.split("."); + let current: unknown = obj; + for (const key of keys) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[key]; + } + return typeof current === "string" ? current : undefined; +} + +function interpolate(str: string, vars?: Record): string { + if (!vars) return str; + return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`)); +} + +export function mainT( + namespace: Namespace, + key: string, + vars?: Record, +): string { + const value = + getMessageValue(messages[currentLocale]?.[namespace], key) ?? + getMessageValue(messages.en?.[namespace], key); + + if (value == null) return `${namespace}.${key}`; + return interpolate(value, vars); +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7738c48f..78d83448 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -17,6 +17,7 @@ import { type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; +import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; const PROJECT_FILE_EXTENSION = "openscreen"; @@ -472,11 +473,13 @@ export function registerIpcHandlers( // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif - ? [{ name: "GIF Image", extensions: ["gif"] }] - : [{ name: "MP4 Video", extensions: ["mp4"] }]; + ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] + : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; const result = await dialog.showSaveDialog({ - title: isGif ? "Save Exported GIF" : "Save Exported Video", + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), defaultPath: path.join(app.getPath("downloads"), fileName), filters, properties: ["createDirectory", "showOverwriteConfirmation"], @@ -510,11 +513,14 @@ export function registerIpcHandlers( ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ - title: "Select Video File", + title: mainT("dialogs", "fileDialogs.selectVideo"), defaultPath: RECORDINGS_DIR, filters: [ - { name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, - { name: "All Files", extensions: ["*"] }, + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, ], properties: ["openFile"], }); @@ -590,10 +596,13 @@ export function registerIpcHandlers( : `${safeName}.${PROJECT_FILE_EXTENSION}`; const result = await dialog.showSaveDialog({ - title: "Save OpenScreen Project", + title: mainT("dialogs", "fileDialogs.saveProject"), defaultPath: path.join(RECORDINGS_DIR, defaultName), filters: [ - { name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] }, + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, { name: "JSON", extensions: ["json"] }, ], properties: ["createDirectory", "showOverwriteConfirmation"], @@ -629,12 +638,15 @@ export function registerIpcHandlers( ipcMain.handle("load-project-file", async () => { try { const result = await dialog.showOpenDialog({ - title: "Open OpenScreen Project", + title: mainT("dialogs", "fileDialogs.openProject"), defaultPath: RECORDINGS_DIR, filters: [ - { name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] }, + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, { name: "JSON", extensions: ["json"] }, - { name: "All Files", extensions: ["*"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, ], properties: ["openFile"], }); diff --git a/electron/main.ts b/electron/main.ts index 86d4e370..7e19d468 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,6 +12,7 @@ import { systemPreferences, Tray, } from "electron"; +import { mainT, setMainLocale } from "./i18n"; import { registerIpcHandlers } from "./ipc/handlers"; import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows"; @@ -130,20 +131,20 @@ function setupApplicationMenu() { template.push( { - label: "File", + label: mainT("common", "actions.file") || "File", submenu: [ { - label: "Load Project…", + label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…", accelerator: "CmdOrCtrl+O", click: () => sendEditorMenuAction("menu-load-project"), }, { - label: "Save Project…", + label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…", accelerator: "CmdOrCtrl+S", click: () => sendEditorMenuAction("menu-save-project"), }, { - label: "Save Project As…", + label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…", accelerator: "CmdOrCtrl+Shift+S", click: () => sendEditorMenuAction("menu-save-project-as"), }, @@ -151,7 +152,7 @@ function setupApplicationMenu() { ], }, { - label: "Edit", + label: mainT("common", "actions.edit") || "Edit", submenu: [ { role: "undo" }, { role: "redo" }, @@ -163,7 +164,7 @@ function setupApplicationMenu() { ], }, { - label: "View", + label: mainT("common", "actions.view") || "View", submenu: [ { role: "reload" }, { role: "forceReload" }, @@ -177,7 +178,7 @@ function setupApplicationMenu() { ], }, { - label: "Window", + label: mainT("common", "actions.window") || "Window", submenu: isMac ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] : [{ role: "minimize" }, { role: "close" }], @@ -215,7 +216,7 @@ function updateTrayMenu(recording: boolean = false) { const menuTemplate = recording ? [ { - label: "Stop Recording", + label: mainT("common", "actions.stopRecording") || "Stop Recording", click: () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("stop-recording-from-tray"); @@ -225,13 +226,13 @@ function updateTrayMenu(recording: boolean = false) { ] : [ { - label: "Open", + label: mainT("common", "actions.open") || "Open", click: () => { showMainWindow(); }, }, { - label: "Quit", + label: mainT("common", "actions.quit") || "Quit", click: () => { app.quit(); }, @@ -281,12 +282,16 @@ function createEditorWindowWrapper() { const choice = dialog.showMessageBoxSync(mainWindow!, { type: "warning", - buttons: ["Save & Close", "Discard & Close", "Cancel"], + buttons: [ + mainT("dialogs", "unsavedChanges.saveAndClose"), + mainT("dialogs", "unsavedChanges.discardAndClose"), + mainT("common", "actions.cancel"), + ], defaultId: 0, cancelId: 2, - title: "Unsaved Changes", - message: "You have unsaved changes.", - detail: "Do you want to save your project before closing?", + title: mainT("dialogs", "unsavedChanges.title"), + message: mainT("dialogs", "unsavedChanges.message"), + detail: mainT("dialogs", "unsavedChanges.detail"), }); const windowToClose = mainWindow; @@ -354,6 +359,12 @@ app.whenReady().then(async () => { ipcMain.on("hud-overlay-close", () => { app.quit(); }); + ipcMain.handle("set-locale", (_, locale: string) => { + setMainLocale(locale); + setupApplicationMenu(); + updateTrayMenu(); + }); + createTray(); updateTrayMenu(); setupApplicationMenu(); diff --git a/electron/preload.ts b/electron/preload.ts index ac9451d2..8f1836bd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -115,6 +115,9 @@ contextBridge.exposeInMainWorld("electronAPI", { saveShortcuts: (shortcuts: unknown) => { return ipcRenderer.invoke("save-shortcuts", shortcuts); }, + setLocale: (locale: string) => { + return ipcRenderer.invoke("set-locale", locale); + }, setMicrophoneExpanded: (expanded: boolean) => { ipcRenderer.send("hud:setMicrophoneExpanded", expanded); }, diff --git a/package.json b/package.json index d59420c6..e74a9fae 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", + "i18n:check": "node scripts/i18n-check.mjs", "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", "build:win": "tsc && vite build && electron-builder --win", diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs new file mode 100644 index 00000000..3fd0331f --- /dev/null +++ b/scripts/i18n-check.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Validates that all locale translation files have identical key structures. + * Compares zh-CN and es against the en baseline for every namespace. + * + * Usage: node scripts/i18n-check.mjs + */ + +import fs from "node:fs"; +import path from "node:path"; + +const LOCALES_DIR = path.resolve("src/i18n/locales"); +const BASE_LOCALE = "en"; +const COMPARE_LOCALES = ["zh-CN", "es"]; + +function getKeys(obj, prefix = "") { + const keys = []; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + keys.push(...getKeys(value, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys.sort(); +} + +let hasErrors = false; + +const baseDir = path.join(LOCALES_DIR, BASE_LOCALE); +const namespaces = fs + .readdirSync(baseDir) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(".json", "")); + +for (const namespace of namespaces) { + const basePath = path.join(baseDir, `${namespace}.json`); + const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8")); + const baseKeys = getKeys(baseData); + + for (const locale of COMPARE_LOCALES) { + const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`); + + if (!fs.existsSync(localePath)) { + console.error(`MISSING: ${locale}/${namespace}.json does not exist`); + hasErrors = true; + continue; + } + + const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8")); + const localeKeys = getKeys(localeData); + + const missing = baseKeys.filter((k) => !localeKeys.includes(k)); + const extra = localeKeys.filter((k) => !baseKeys.includes(k)); + + if (missing.length > 0) { + console.error(`MISSING in ${locale}/${namespace}.json:`); + for (const key of missing) { + console.error(` - ${key}`); + } + hasErrors = true; + } + + if (extra.length > 0) { + console.error(`EXTRA in ${locale}/${namespace}.json:`); + for (const key of extra) { + console.error(` + ${key}`); + } + hasErrors = true; + } + } +} + +if (hasErrors) { + console.error("\ni18n check FAILED — translation files are out of sync."); + process.exit(1); +} else { + console.log( + `i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, + ); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d18c5721..32f3f102 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Languages } from "lucide-react"; import { useEffect, useState } from "react"; import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; @@ -16,6 +16,9 @@ import { MdVolumeUp, } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; +import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import { getLocaleName } from "@/i18n/loader"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; @@ -62,6 +65,9 @@ const windowBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; export function LaunchWindow() { + const t = useScopedT("launch"); + const { locale, setLocale } = useI18n(); + const { recording, toggleRecording, @@ -187,7 +193,26 @@ export function LaunchWindow() { }; return ( -
+
+ {/* Language switcher — top-left, beside traffic lights */} +
+ + +
+
{/* Mic controls panel */} {showMicControls && ( @@ -244,7 +269,9 @@ export function LaunchWindow() { className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`} onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)} disabled={recording} - title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"} + title={ + systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio") + } > {systemAudioEnabled ? getIcon("volumeOn", "text-green-400") @@ -254,7 +281,7 @@ export function LaunchWindow() { className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`} onClick={toggleMicrophone} disabled={recording} - title={microphoneEnabled ? "Disable microphone" : "Enable microphone"} + title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")} > {microphoneEnabled ? getIcon("micOn", "text-green-400") @@ -265,7 +292,7 @@ export function LaunchWindow() { onClick={async () => { await setWebcamEnabled(!webcamEnabled); }} - title={webcamEnabled ? "Disable webcam" : "Enable webcam"} + title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")} > {webcamEnabled ? getIcon("webcamOn", "text-green-400") @@ -296,7 +323,7 @@ export function LaunchWindow() { {/* Restart recording */} {recording && ( - + -
diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 0d94efac..5768c3ac 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { MdCheck } from "react-icons/md"; +import { useScopedT } from "@/contexts/I18nContext"; import { Button } from "../ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import styles from "./SourceSelector.module.css"; @@ -13,6 +14,8 @@ interface DesktopSource { } export function SourceSelector() { + const t = useScopedT("launch"); + const tc = useScopedT("common"); const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [loading, setLoading] = useState(true); @@ -63,7 +66,7 @@ export function SourceSelector() { >
-

Loading sources...

+

{t("sourceSelector.loading")}

); @@ -113,13 +116,13 @@ export function SourceSelector() { value="screens" className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all" > - Screens ({screenSources.length}) + {t("sourceSelector.screens", { count: String(screenSources.length) })} - Windows ({windowSources.length}) + {t("sourceSelector.windows", { count: String(windowSources.length) })}
@@ -146,14 +149,14 @@ export function SourceSelector() { onClick={() => window.close()} className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full" > - Cancel + {tc("actions.cancel")}
diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index b1a321fb..9ab9ce3d 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useScopedT } from "@/contexts/I18nContext"; import { addCustomFont, type CustomFont, @@ -25,6 +26,8 @@ interface AddCustomFontDialogProps { } export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { + const t = useScopedT("settings"); + const tc = useScopedT("common"); const [open, setOpen] = useState(false); const [importUrl, setImportUrl] = useState(""); const [fontName, setFontName] = useState(""); @@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { const handleAdd = async () => { // Validate inputs if (!importUrl.trim()) { - toast.error("Please enter a Google Fonts import URL"); + toast.error(t("customFont.errorEmptyUrl")); return; } if (!isValidGoogleFontsUrl(importUrl)) { - toast.error("Please enter a valid Google Fonts URL"); + toast.error(t("customFont.errorInvalidUrl")); return; } if (!fontName.trim()) { - toast.error("Please enter a font name"); + toast.error(t("customFont.errorEmptyName")); return; } @@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { // Extract font family from URL const fontFamily = parseFontFamilyFromImport(importUrl); if (!fontFamily) { - toast.error("Could not extract font family from URL"); + toast.error(t("customFont.errorExtractFailed")); setLoading(false); return; } @@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onFontAdded(newFont); } - toast.success(`Font "${fontName}" added successfully`); + toast.success(t("customFont.successMessage", { fontName })); // Reset and close setImportUrl(""); @@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { } catch (error) { console.error("Failed to add custom font:", error); const errorMessage = error instanceof Error ? error.message : "Failed to load font"; - toast.error("Failed to add font", { + toast.error(t("customFont.failedToAdd"), { description: errorMessage.includes("timeout") - ? "Font took too long to load. Please check the URL and try again." - : "The font could not be loaded. Please verify the Google Fonts URL is correct.", + ? t("customFont.errorTimeout") + : t("customFont.errorLoadFailed"), }); } finally { setLoading(false); @@ -114,12 +117,12 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs" > - Add Google Font + {t("customFont.dialogTitle")} - Add Google Font + {t("customFont.dialogTitle")} Add a custom font from Google Fonts to use in your annotations. @@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
handleImportUrlChange(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" /> -

- Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL -

+

{t("customFont.urlHelp")}

setFontName(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" /> -

- This is how the font will appear in the font selector -

+

{t("customFont.nameHelp")}

@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onClick={() => setOpen(false)} className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10" > - Cancel + {tc("actions.cancel")}
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 94f31edb..b289392e 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -27,6 +27,7 @@ import { import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useScopedT } from "@/contexts/I18nContext"; import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; import { cn } from "@/lib/utils"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; @@ -43,14 +44,14 @@ interface AnnotationSettingsPanelProps { } const FONT_FAMILIES = [ - { value: "system-ui, -apple-system, sans-serif", label: "Classic" }, - { value: "Georgia, serif", label: "Editor" }, - { value: "Impact, Arial Black, sans-serif", label: "Strong" }, - { value: "Courier New, monospace", label: "Typewriter" }, - { value: "Brush Script MT, cursive", label: "Deco" }, - { value: "Arial, sans-serif", label: "Simple" }, - { value: "Verdana, sans-serif", label: "Modern" }, - { value: "Trebuchet MS, sans-serif", label: "Clean" }, + { value: "system-ui, -apple-system, sans-serif", labelKey: "classic" }, + { value: "Georgia, serif", labelKey: "editor" }, + { value: "Impact, Arial Black, sans-serif", labelKey: "strong" }, + { value: "Courier New, monospace", labelKey: "typewriter" }, + { value: "Brush Script MT, cursive", labelKey: "deco" }, + { value: "Arial, sans-serif", labelKey: "simple" }, + { value: "Verdana, sans-serif", labelKey: "modern" }, + { value: "Trebuchet MS, sans-serif", labelKey: "clean" }, ]; const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; @@ -63,9 +64,21 @@ export function AnnotationSettingsPanel({ onFigureDataChange, onDelete, }: AnnotationSettingsPanelProps) { + const t = useScopedT("settings"); const fileInputRef = useRef(null); const [customFonts, setCustomFonts] = useState([]); + const fontStyleLabels: Record = { + classic: t("fontStyles.classic"), + editor: t("fontStyles.editor"), + strong: t("fontStyles.strong"), + typewriter: t("fontStyles.typewriter"), + deco: t("fontStyles.deco"), + simple: t("fontStyles.simple"), + modern: t("fontStyles.modern"), + clean: t("fontStyles.clean"), + }; + // Load custom fonts on mount useEffect(() => { setCustomFonts(getCustomFonts()); @@ -99,8 +112,8 @@ export function AnnotationSettingsPanel({ // Validate file type const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { - toast.error("Invalid file type", { - description: "Please upload a JPG, PNG, GIF, or WebP image file.", + toast.error(t("annotation.invalidImageType"), { + description: t("annotation.imageFormatsOnly"), }); event.target.value = ""; return; @@ -112,12 +125,12 @@ export function AnnotationSettingsPanel({ const dataUrl = e.target?.result as string; if (dataUrl) { onContentChange(dataUrl); - toast.success("Image uploaded successfully!"); + toast.success(t("annotation.imageUploadSuccess")); } }; reader.onerror = () => { - toast.error("Failed to upload image", { + toast.error(t("annotation.failedImageUpload"), { description: "There was an error reading the file.", }); }; @@ -130,9 +143,9 @@ export function AnnotationSettingsPanel({
- Annotation Settings + {t("annotation.title")} - Active + {t("annotation.active")}
@@ -148,14 +161,14 @@ export function AnnotationSettingsPanel({ className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2" > - Text + {t("annotation.typeText")} - Image + {t("annotation.typeImage")} - Arrow + {t("annotation.typeArrow")} {/* Text Content */}
- +