lang support
This commit is contained in:
Vendored
+1
@@ -136,6 +136,7 @@ interface Window {
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
setLocale: (locale: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
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<string, unknown>)[key];
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function interpolate(str: string, vars?: Record<string, string | number>): 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, string | number>,
|
||||
): string {
|
||||
const value =
|
||||
getMessageValue(messages[currentLocale]?.[namespace], key) ??
|
||||
getMessageValue(messages.en?.[namespace], key);
|
||||
|
||||
if (value == null) return `${namespace}.${key}`;
|
||||
return interpolate(value, vars);
|
||||
}
|
||||
+23
-11
@@ -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"],
|
||||
});
|
||||
|
||||
+25
-14
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent relative">
|
||||
{/* Language switcher — top-left, beside traffic lights */}
|
||||
<div
|
||||
className={`absolute top-2 left-[72px] flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* 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 && (
|
||||
<Tooltip content="Restart recording">
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
@@ -307,7 +334,7 @@ export function LaunchWindow() {
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content="Open video file">
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
@@ -318,7 +345,7 @@ export function LaunchWindow() {
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content="Open project">
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
@@ -330,10 +357,18 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -63,7 +66,7 @@ export function SourceSelector() {
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
||||
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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) })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="windows"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
>
|
||||
Windows ({windowSources.length})
|
||||
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -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")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
>
|
||||
Share
|
||||
{tc("actions.share")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Google Font
|
||||
{t("customFont.dialogTitle")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Google Font</DialogTitle>
|
||||
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Add a custom font from Google Fonts to use in your annotations.
|
||||
</DialogDescription>
|
||||
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-url" className="text-slate-200">
|
||||
Google Fonts Import URL
|
||||
{t("customFont.urlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="import-url"
|
||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
||||
placeholder={t("customFont.urlPlaceholder")}
|
||||
value={importUrl}
|
||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font-name" className="text-slate-200">
|
||||
Display Name
|
||||
{t("customFont.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="font-name"
|
||||
placeholder="My Custom Font"
|
||||
placeholder={t("customFont.namePlaceholder")}
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
This is how the font will appear in the font selector
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
@@ -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")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Font"}
|
||||
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
||||
|
||||
const fontStyleLabels: Record<string, string> = {
|
||||
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({
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
Active
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Type className="w-4 h-4" />
|
||||
Text
|
||||
{t("annotation.typeText")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Image
|
||||
{t("annotation.typeImage")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="figure"
|
||||
@@ -170,18 +183,20 @@ export function AnnotationSettingsPanel({
|
||||
>
|
||||
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Arrow
|
||||
{t("annotation.typeArrow")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Text Content */}
|
||||
<TabsContent value="text" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
{t("annotation.textContent")}
|
||||
</label>
|
||||
<textarea
|
||||
value={annotation.textContent || annotation.content}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
placeholder="Enter your text..."
|
||||
placeholder={t("annotation.textPlaceholder")}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
||||
/>
|
||||
@@ -193,14 +208,14 @@ export function AnnotationSettingsPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Font Style
|
||||
{t("annotation.fontStyle")}
|
||||
</label>
|
||||
<Select
|
||||
value={annotation.style.fontFamily}
|
||||
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue placeholder="Select style" />
|
||||
<SelectValue placeholder={t("annotation.selectStyle")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
||||
{FONT_FAMILIES.map((font) => (
|
||||
@@ -209,13 +224,13 @@ export function AnnotationSettingsPanel({
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
{fontStyleLabels[font.labelKey]}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
Custom Fonts
|
||||
{t("annotation.customFonts")}
|
||||
</div>
|
||||
{customFonts.map((font) => (
|
||||
<SelectItem
|
||||
@@ -232,13 +247,15 @@ export function AnnotationSettingsPanel({
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
{t("annotation.size")}
|
||||
</label>
|
||||
<Select
|
||||
value={annotation.style.fontSize.toString()}
|
||||
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue placeholder="Size" />
|
||||
<SelectValue placeholder={t("annotation.size")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
||||
{FONT_SIZES.map((size) => (
|
||||
@@ -345,7 +362,7 @@ export function AnnotationSettingsPanel({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Text Color
|
||||
{t("annotation.textColor")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -379,7 +396,7 @@ export function AnnotationSettingsPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Background
|
||||
{t("annotation.background")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -395,7 +412,9 @@ export function AnnotationSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
|
||||
{annotation.style.backgroundColor === "transparent"
|
||||
? t("annotation.none")
|
||||
: t("annotation.color")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
@@ -423,7 +442,7 @@ export function AnnotationSettingsPanel({
|
||||
onStyleChange({ backgroundColor: "transparent" });
|
||||
}}
|
||||
>
|
||||
Clear Background
|
||||
{t("annotation.clearBackground")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -447,7 +466,7 @@ export function AnnotationSettingsPanel({
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload Image
|
||||
{t("annotation.uploadImage")}
|
||||
</Button>
|
||||
|
||||
{annotation.content && annotation.content.startsWith("data:image") && (
|
||||
@@ -461,14 +480,14 @@ export function AnnotationSettingsPanel({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
||||
Supported formats: JPG, PNG, GIF, WebP
|
||||
{t("annotation.supportedFormats")}
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
||||
Arrow Direction
|
||||
{t("annotation.arrowDirection")}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(
|
||||
@@ -517,7 +536,9 @@ export function AnnotationSettingsPanel({
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
||||
{t("annotation.strokeWidth", {
|
||||
width: String(annotation.figureData?.strokeWidth || 4),
|
||||
})}
|
||||
</label>
|
||||
<Slider
|
||||
value={[annotation.figureData?.strokeWidth || 4]}
|
||||
@@ -536,7 +557,9 @@ export function AnnotationSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
{t("annotation.arrowColor")}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -581,28 +604,18 @@ export function AnnotationSettingsPanel({
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Annotation
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">Shortcuts & Tips</span>
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>Move playhead to overlapping annotation section and select an item.</li>
|
||||
<li>
|
||||
Use{" "}
|
||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
|
||||
cycle through overlapping items.
|
||||
</li>
|
||||
<li>
|
||||
Use{" "}
|
||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
|
||||
Shift+Tab
|
||||
</kbd>{" "}
|
||||
to cycle backwards.
|
||||
</li>
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
<li>{t("annotation.tipTabCycle")}</li>
|
||||
<li>{t("annotation.tipShiftTabCycle")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import type { ExportProgress } from "@/lib/exporter";
|
||||
|
||||
interface ExportDialogProps {
|
||||
@@ -26,6 +27,7 @@ export function ExportDialog({
|
||||
exportedFilePath,
|
||||
onShowInFolder,
|
||||
}: ExportDialogProps) {
|
||||
const t = useScopedT("dialogs");
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Reset showSuccess when a new export starts or dialog reopens
|
||||
@@ -65,25 +67,25 @@ export function ExportDialog({
|
||||
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return "Please try again";
|
||||
if (error) return t("export.tryAgain");
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (exportFormat === "mp4") {
|
||||
return "Finalizing video export...";
|
||||
return t("export.finalizingVideo");
|
||||
}
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
return t("export.compilingGifProgress", { progress: String(renderProgress) });
|
||||
}
|
||||
return "Compiling GIF... This may take a while";
|
||||
return t("export.compilingGifWait");
|
||||
}
|
||||
return "This may take a moment...";
|
||||
return t("export.takeMoment");
|
||||
};
|
||||
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return "Export Failed";
|
||||
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
|
||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
||||
return `Exporting ${formatLabel}`;
|
||||
if (error) return t("export.failed");
|
||||
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
|
||||
if (isCompiling || isFinalizing) return t("export.compilingGif");
|
||||
return t("export.exportingFormat", { format: formatLabel });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -101,9 +103,11 @@ export function ExportDialog({
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-xl font-bold text-slate-200 block">
|
||||
{t("export.complete")}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
Your {formatLabel.toLowerCase()} is ready
|
||||
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
|
||||
</span>
|
||||
{exportedFilePath && (
|
||||
<Button
|
||||
@@ -111,7 +115,7 @@ export function ExportDialog({
|
||||
onClick={onShowInFolder}
|
||||
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
||||
>
|
||||
Show in Folder
|
||||
{t("export.showInFolder")}
|
||||
</Button>
|
||||
)}
|
||||
{exportedFilePath && (
|
||||
@@ -166,7 +170,11 @@ export function ExportDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
|
||||
<span>
|
||||
{isCompiling || isFinalizing
|
||||
? t("export.compiling")
|
||||
: t("export.renderingFrames")}
|
||||
</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
@@ -174,7 +182,7 @@ export function ExportDialog({
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
{t("export.processing")}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
@@ -218,19 +226,19 @@ export function ExportDialog({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
||||
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isFinalizing && exportFormat === "mp4"
|
||||
? "Finalizing..."
|
||||
? t("export.finalizing")
|
||||
: isCompiling || isFinalizing
|
||||
? "Compiling..."
|
||||
? t("export.compilingStatus")
|
||||
: formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
Frames
|
||||
{t("export.frames")}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
@@ -245,7 +253,7 @@ export function ExportDialog({
|
||||
variant="destructive"
|
||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||
>
|
||||
Cancel Export
|
||||
{t("export.cancelExport")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -254,7 +262,9 @@ export function ExportDialog({
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
|
||||
<p className="text-lg text-slate-200 font-medium">
|
||||
{t("export.savedSuccessfully", { format: formatLabel })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Film, Image } from "lucide-react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import type { ExportFormat } from "@/lib/exporter/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{
|
||||
value: "mp4",
|
||||
label: "MP4 Video",
|
||||
description: "High quality video file",
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: "gif",
|
||||
label: "GIF Animation",
|
||||
description: "Animated image for sharing",
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
|
||||
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
|
||||
{ value: "gif", icon: <Image className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
@@ -35,10 +19,18 @@ export function FormatSelector({
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
const t = useScopedT("settings");
|
||||
|
||||
const formatLabels: Record<ExportFormat, { label: string; description: string }> = {
|
||||
mp4: { label: t("exportFormat.mp4Video"), description: t("exportFormat.mp4Description") },
|
||||
gif: { label: t("exportFormat.gifAnimation"), description: t("exportFormat.gifDescription") },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
const labels = formatLabels[option.value];
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -63,8 +55,8 @@ export function FormatSelector({
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
<div className="font-medium text-sm">{labels.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
const t = useScopedT("shortcuts");
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
@@ -11,7 +13,7 @@ export function KeyboardShortcutsHelp() {
|
||||
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
@@ -19,14 +21,14 @@ export function KeyboardShortcutsHelp() {
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
{t("customize")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<span className="text-slate-400">{t(`actions.${action}`)}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function PlaybackControls({
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
}: PlaybackControlsProps) {
|
||||
const t = useScopedT("common");
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -41,7 +44,7 @@ export default function PlaybackControls({
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||
)}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
@@ -217,6 +218,7 @@ export function SettingsPanel({
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
onWebcamLayoutPresetChange,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -382,8 +384,8 @@ export function SettingsPanel({
|
||||
// Validate file type - only allow JPG/JPEG
|
||||
const validTypes = ["image/jpeg", "image/jpg"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Invalid file type", {
|
||||
description: "Please upload a JPG or JPEG image file.",
|
||||
toast.error(t("imageUpload.invalidFileType"), {
|
||||
description: t("imageUpload.jpgOnly"),
|
||||
});
|
||||
event.target.value = "";
|
||||
return;
|
||||
@@ -396,13 +398,13 @@ export function SettingsPanel({
|
||||
if (dataUrl) {
|
||||
setCustomImages((prev) => [...prev, dataUrl]);
|
||||
onWallpaperChange(dataUrl);
|
||||
toast.success("Custom image uploaded successfully!");
|
||||
toast.success(t("imageUpload.uploadSuccess"));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to upload image", {
|
||||
description: "There was an error reading the file.",
|
||||
toast.error(t("imageUpload.failedToUpload"), {
|
||||
description: t("imageUpload.errorReading"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -468,7 +470,7 @@ export function SettingsPanel({
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
|
||||
<span className="text-sm font-medium text-slate-200">{t("zoom.level")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{zoomEnabled && selectedZoomDepth && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
|
||||
@@ -502,9 +504,7 @@ export function SettingsPanel({
|
||||
})}
|
||||
</div>
|
||||
{!zoomEnabled && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">
|
||||
Select a zoom region to adjust
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
@@ -514,7 +514,7 @@ export function SettingsPanel({
|
||||
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Zoom
|
||||
{t("zoom.deleteZoom")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -528,14 +528,14 @@ export function SettingsPanel({
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Trim Region
|
||||
{t("trim.deleteRegion")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
|
||||
<span className="text-sm font-medium text-slate-200">{t("speed.playbackSpeed")}</span>
|
||||
{selectedSpeedId && selectedSpeedValue && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
||||
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
|
||||
@@ -569,9 +569,7 @@ export function SettingsPanel({
|
||||
})}
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">
|
||||
Select a speed region to adjust
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
|
||||
)}
|
||||
{selectedSpeedId && (
|
||||
<Button
|
||||
@@ -581,7 +579,7 @@ export function SettingsPanel({
|
||||
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Speed Region
|
||||
{t("speed.deleteRegion")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -599,12 +597,14 @@ export function SettingsPanel({
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">Layout</span>
|
||||
<span className="text-xs font-medium">{t("layout.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">Preset</div>
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
|
||||
{t("layout.preset")}
|
||||
</div>
|
||||
<Select
|
||||
value={webcamLayoutPreset}
|
||||
onValueChange={(value: WebcamLayoutPreset) =>
|
||||
@@ -612,12 +612,14 @@ export function SettingsPanel({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
|
||||
<SelectValue placeholder="Select preset" />
|
||||
<SelectValue placeholder={t("layout.selectPreset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||
{preset.label}
|
||||
{preset.value === "picture-in-picture"
|
||||
? t("layout.pictureInPicture")
|
||||
: t("layout.verticalStack")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -631,13 +633,15 @@ export function SettingsPanel({
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">Video Effects</span>
|
||||
<span className="text-xs font-medium">{t("effects.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.blurBg")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={showBlur}
|
||||
onCheckedChange={onBlurChange}
|
||||
@@ -649,9 +653,11 @@ export function SettingsPanel({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.motionBlur")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)}
|
||||
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -666,7 +672,9 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.shadow")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{Math.round(shadowIntensity * 100)}%
|
||||
</span>
|
||||
@@ -683,7 +691,9 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.roundness")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -698,7 +708,9 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-slate-300">Padding</div>
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.padding")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -719,7 +731,7 @@ export function SettingsPanel({
|
||||
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
|
||||
>
|
||||
<Crop className="w-3 h-3" />
|
||||
Crop Video
|
||||
{t("crop.cropVideo")}
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -731,7 +743,7 @@ export function SettingsPanel({
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">Background</span>
|
||||
<span className="text-xs font-medium">{t("background.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
@@ -741,19 +753,19 @@ export function SettingsPanel({
|
||||
value="image"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||
>
|
||||
Image
|
||||
{t("background.image")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="color"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||
>
|
||||
Color
|
||||
{t("background.color")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="gradient"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||
>
|
||||
Gradient
|
||||
{t("background.gradient")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -772,7 +784,7 @@ export function SettingsPanel({
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
Upload Custom
|
||||
{t("background.uploadCustom")}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
@@ -873,7 +885,7 @@ export function SettingsPanel({
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={`Gradient ${idx + 1}`}
|
||||
aria-label={t("background.gradientLabel", { index: idx + 1 })}
|
||||
onClick={() => {
|
||||
setGradient(g);
|
||||
onWallpaperChange(g);
|
||||
@@ -899,10 +911,8 @@ export function SettingsPanel({
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200">Crop Video</span>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
Drag on each side to adjust the crop area
|
||||
</p>
|
||||
<span className="text-xl font-bold text-slate-200">{t("crop.cropVideo")}</span>
|
||||
<p className="text-sm text-slate-400 mt-2">{t("crop.dragInstruction")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -944,7 +954,7 @@ export function SettingsPanel({
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
Ratio
|
||||
{t("crop.ratio")}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
@@ -953,7 +963,7 @@ export function SettingsPanel({
|
||||
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-[#1a1a1f] text-slate-200">
|
||||
Free
|
||||
{t("crop.free")}
|
||||
</option>
|
||||
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
|
||||
16:9
|
||||
@@ -983,7 +993,9 @@ export function SettingsPanel({
|
||||
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
|
||||
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
title={
|
||||
cropAspectLocked ? t("crop.unlockAspectRatio") : t("crop.lockAspectRatio")
|
||||
}
|
||||
>
|
||||
{cropAspectLocked ? (
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
@@ -1005,7 +1017,7 @@ export function SettingsPanel({
|
||||
size="lg"
|
||||
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
||||
>
|
||||
Done
|
||||
{t("crop.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1025,7 +1037,7 @@ export function SettingsPanel({
|
||||
)}
|
||||
>
|
||||
<Film className="w-3.5 h-3.5" />
|
||||
MP4
|
||||
{t("exportFormat.mp4")}
|
||||
</button>
|
||||
<button
|
||||
data-testid={getTestId("gif-format-button")}
|
||||
@@ -1038,7 +1050,7 @@ export function SettingsPanel({
|
||||
)}
|
||||
>
|
||||
<Image className="w-3.5 h-3.5" />
|
||||
GIF
|
||||
{t("exportFormat.gif")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1053,7 +1065,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
Low
|
||||
{t("exportQuality.low")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("good")}
|
||||
@@ -1064,7 +1076,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
Medium
|
||||
{t("exportQuality.medium")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExportQualityChange?.("source")}
|
||||
@@ -1075,7 +1087,7 @@ export function SettingsPanel({
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
High
|
||||
{t("exportQuality.high")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1122,7 +1134,7 @@ export function SettingsPanel({
|
||||
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-400">Loop</span>
|
||||
<span className="text-[10px] text-slate-400">{t("gifSettings.loop")}</span>
|
||||
<Switch
|
||||
checked={gifLoop}
|
||||
onCheckedChange={onGifLoopChange}
|
||||
@@ -1141,7 +1153,7 @@ export function SettingsPanel({
|
||||
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
Load Project
|
||||
{t("project.load")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1150,7 +1162,7 @@ export function SettingsPanel({
|
||||
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save Project
|
||||
{t("project.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1162,7 +1174,7 @@ export function SettingsPanel({
|
||||
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Choose Save Location
|
||||
{t("export.chooseSaveLocation")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -1173,7 +1185,7 @@ export function SettingsPanel({
|
||||
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {exportFormat === "gif" ? "GIF" : "Video"}
|
||||
{exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
@@ -1187,7 +1199,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
Report Bug
|
||||
{t("links.reportBug")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1197,7 +1209,7 @@ export function SettingsPanel({
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<Star className="w-3 h-3 text-yellow-400" />
|
||||
Star on GitHub
|
||||
{t("links.starOnGithub")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
findConflict,
|
||||
formatBinding,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_LABELS,
|
||||
type ShortcutAction,
|
||||
type ShortcutBinding,
|
||||
type ShortcutConflict,
|
||||
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
export function ShortcutsConfigDialog() {
|
||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||
useShortcuts();
|
||||
const t = useScopedT("shortcuts");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
|
||||
setCaptureFor(null);
|
||||
|
||||
if (found?.type === "fixed") {
|
||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
||||
toast.error(t("reservedShortcut", { label: found.label }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
|
||||
|
||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||
}, [captureFor, draft]);
|
||||
}, [captureFor, draft, t]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success("Keyboard shortcuts saved");
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||
toast.info("Reset to default shortcuts — click Save to apply");
|
||||
}, []);
|
||||
toast.info(t("resetToast"));
|
||||
}, [t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureFor(null);
|
||||
@@ -128,13 +130,13 @@ export function ShortcutsConfigDialog() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
Keyboard Shortcuts
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Configurable
|
||||
{t("configurable")}
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
@@ -142,14 +144,14 @@ export function ShortcutsConfigDialog() {
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
@@ -159,14 +161,14 @@ export function ShortcutsConfigDialog() {
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠ Already used by{" "}
|
||||
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
||||
⚠{" "}
|
||||
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
@@ -174,14 +176,14 @@ export function ShortcutsConfigDialog() {
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
Swap
|
||||
{t("swap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +195,7 @@ export function ShortcutsConfigDialog() {
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Fixed
|
||||
{t("fixed")}
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
<div
|
||||
@@ -208,10 +210,7 @@ export function ShortcutsConfigDialog() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Click a shortcut then press the new key combination. Press{" "}
|
||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
@@ -221,18 +220,18 @@ export function ShortcutsConfigDialog() {
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset to defaults
|
||||
{t("resetToDefaults")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
{tc("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
export function TutorialHelp() {
|
||||
const t = useScopedT("dialogs");
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
@@ -19,33 +21,33 @@ export function TutorialHelp() {
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">How trimming works</span>
|
||||
<span className="font-medium">{t("tutorial.triggerLabel")}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
{t("tutorial.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
||||
that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
||||
be cut out when you export.
|
||||
{t("tutorial.explanationBefore")}
|
||||
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
|
||||
{t("tutorial.explanationMiddle")}
|
||||
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
|
||||
{t("tutorial.explanationAfter")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
{t("tutorial.visualExample")}
|
||||
</h3>
|
||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||
{/* Background track (Kept parts) */}
|
||||
@@ -58,7 +60,7 @@ export function TutorialHelp() {
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
{t("tutorial.removed")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
@@ -67,13 +69,19 @@ export function TutorialHelp() {
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
{t("tutorial.removed")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||
{t("tutorial.kept")}
|
||||
</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
|
||||
{t("tutorial.kept")}
|
||||
</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
|
||||
{t("tutorial.kept")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-2">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||
@@ -84,38 +92,38 @@ export function TutorialHelp() {
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 1</span>
|
||||
<span className="text-[10px] text-white font-medium">{t("tutorial.part1")}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 2</span>
|
||||
<span className="text-[10px] text-white font-medium">{t("tutorial.part2")}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 3</span>
|
||||
<span className="text-[10px] text-white font-medium">{t("tutorial.part3")}</span>
|
||||
</div>
|
||||
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
|
||||
<span className="absolute right-4 text-xs text-slate-400">
|
||||
{t("tutorial.finalVideo")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
|
||||
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step1Title")}</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Press
|
||||
{t("tutorial.step1DescriptionBefore")}
|
||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||
or click the scissors icon to mark a section for removal.
|
||||
{t("tutorial.step1DescriptionAfter")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
||||
</p>
|
||||
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
|
||||
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { Languages } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { toast } from "sonner";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -117,6 +121,9 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const t = useScopedT("editor");
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
@@ -338,12 +345,12 @@ export default function VideoEditor() {
|
||||
const saveProject = useCallback(
|
||||
async (forceSaveAs: boolean) => {
|
||||
if (!videoPath) {
|
||||
toast.error("No video loaded");
|
||||
toast.error(t("errors.noVideoLoaded"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentProjectMedia) {
|
||||
toast.error("Unable to determine source video path");
|
||||
toast.error(t("errors.unableToDetermineSourcePath"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -381,12 +388,12 @@ export default function VideoEditor() {
|
||||
);
|
||||
|
||||
if (result.canceled) {
|
||||
toast.info("Project save canceled");
|
||||
toast.info(t("project.saveCanceled"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to save project");
|
||||
toast.error(result.message || t("project.failedToSave"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -395,7 +402,7 @@ export default function VideoEditor() {
|
||||
}
|
||||
setLastSavedSnapshot(projectSnapshot);
|
||||
|
||||
toast.success(`Project saved to ${result.path}`);
|
||||
toast.success(t("project.savedTo", { path: result.path ?? "" }));
|
||||
return true;
|
||||
},
|
||||
[
|
||||
@@ -420,6 +427,7 @@ export default function VideoEditor() {
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
videoPath,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1357,7 +1365,26 @@ export default function VideoEditor() {
|
||||
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex-1" />
|
||||
<div className="flex-1 flex items-center">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 ml-14 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -546,6 +547,7 @@ function Timeline({
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const t = useScopedT("timeline");
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -656,7 +658,7 @@ function Timeline({
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
|
||||
{zoomItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -673,7 +675,7 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -692,7 +694,7 @@ function Timeline({
|
||||
<Row
|
||||
id={ANNOTATION_ROW_ID}
|
||||
isEmpty={annotationItems.length === 0}
|
||||
hint="Press A to add annotation"
|
||||
hint={t("hints.pressAnnotation")}
|
||||
>
|
||||
{annotationItems.map((item) => (
|
||||
<Item
|
||||
@@ -709,7 +711,7 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -762,6 +764,7 @@ export default function TimelineEditor({
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
const t = useScopedT("timeline");
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||
@@ -966,15 +969,15 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place zoom here", {
|
||||
description: "Zoom already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceZoom"), {
|
||||
description: t("errors.zoomExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
|
||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
|
||||
|
||||
const handleSuggestZooms = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||
@@ -982,13 +985,13 @@ export default function TimelineEditor({
|
||||
}
|
||||
|
||||
if (!onZoomSuggested) {
|
||||
toast.error("Zoom suggestion handler unavailable");
|
||||
toast.error(t("errors.zoomSuggestionUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursorTelemetry.length < 2) {
|
||||
toast.info("No cursor telemetry available", {
|
||||
description: "Record a screencast first to generate cursor-based suggestions.",
|
||||
toast.info(t("errors.noCursorTelemetry"), {
|
||||
description: t("errors.noCursorTelemetryDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1005,8 +1008,8 @@ export default function TimelineEditor({
|
||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||
|
||||
if (normalizedSamples.length < 2) {
|
||||
toast.info("No usable cursor telemetry", {
|
||||
description: "The recording does not include enough cursor movement data.",
|
||||
toast.info(t("errors.noUsableTelemetry"), {
|
||||
description: t("errors.noUsableTelemetryDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1014,8 +1017,8 @@ export default function TimelineEditor({
|
||||
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
||||
|
||||
if (dwellCandidates.length === 0) {
|
||||
toast.info("No clear cursor dwell moments found", {
|
||||
description: "Try a recording with slower cursor pauses on important actions.",
|
||||
toast.info(t("errors.noDwellMoments"), {
|
||||
description: t("errors.noDwellMomentsDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1052,13 +1055,17 @@ export default function TimelineEditor({
|
||||
});
|
||||
|
||||
if (addedCount === 0) {
|
||||
toast.info("No auto-zoom slots available", {
|
||||
description: "Detected dwell points overlap existing zoom regions.",
|
||||
toast.info(t("errors.noAutoZoomSlots"), {
|
||||
description: t("errors.noAutoZoomSlotsDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
|
||||
toast.success(
|
||||
addedCount === 1
|
||||
? t("success.addedZoomSuggestions", { count: String(addedCount) })
|
||||
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
|
||||
);
|
||||
}, [
|
||||
videoDuration,
|
||||
totalMs,
|
||||
@@ -1066,6 +1073,7 @@ export default function TimelineEditor({
|
||||
zoomRegions,
|
||||
onZoomSuggested,
|
||||
cursorTelemetry,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleAddTrim = useCallback(() => {
|
||||
@@ -1090,15 +1098,15 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place trim here", {
|
||||
description: "Trim already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceTrim"), {
|
||||
description: t("errors.trimExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
|
||||
|
||||
const handleAddSpeed = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||
@@ -1122,15 +1130,23 @@ export default function TimelineEditor({
|
||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||
);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place speed here", {
|
||||
description: "Speed region already exists at this location or not enough space available.",
|
||||
toast.error(t("errors.cannotPlaceSpeed"), {
|
||||
description: t("errors.speedExistsAtLocation"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
||||
}, [
|
||||
videoDuration,
|
||||
totalMs,
|
||||
currentTimeMs,
|
||||
speedRegions,
|
||||
onSpeedAdded,
|
||||
defaultRegionDurationMs,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
@@ -1253,7 +1269,7 @@ export default function TimelineEditor({
|
||||
id: region.id,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Zoom ${index + 1}`,
|
||||
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||
zoomDepth: region.depth,
|
||||
variant: "zoom",
|
||||
}));
|
||||
@@ -1262,7 +1278,7 @@ export default function TimelineEditor({
|
||||
id: region.id,
|
||||
rowId: TRIM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Trim ${index + 1}`,
|
||||
label: t("labels.trimItem", { index: String(index + 1) }),
|
||||
variant: "trim",
|
||||
}));
|
||||
|
||||
@@ -1271,12 +1287,12 @@ export default function TimelineEditor({
|
||||
|
||||
if (region.type === "text") {
|
||||
// Show text preview
|
||||
const preview = region.content.trim() || "Empty text";
|
||||
const preview = region.content.trim() || t("labels.emptyText");
|
||||
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||
} else if (region.type === "image") {
|
||||
label = "Image";
|
||||
label = t("labels.imageItem");
|
||||
} else {
|
||||
label = "Annotation";
|
||||
label = t("labels.annotationItem");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1292,13 +1308,13 @@ export default function TimelineEditor({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Speed ${index + 1}`,
|
||||
label: t("labels.speedItem", { index: String(index + 1) }),
|
||||
speedValue: region.speed,
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1340,8 +1356,8 @@ export default function TimelineEditor({
|
||||
<Plus className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
|
||||
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1356,7 +1372,7 @@ export default function TimelineEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title="Add Zoom (Z)"
|
||||
title={t("buttons.addZoom")}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1365,7 +1381,7 @@ export default function TimelineEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title="Suggest Zooms from Cursor"
|
||||
title={t("buttons.suggestZooms")}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1374,7 +1390,7 @@ export default function TimelineEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||
title="Add Trim (T)"
|
||||
title={t("buttons.addTrim")}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1383,7 +1399,7 @@ export default function TimelineEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||
title="Add Annotation (A)"
|
||||
title={t("buttons.addAnnotation")}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1392,7 +1408,7 @@ export default function TimelineEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title="Add Speed (S)"
|
||||
title={t("buttons.addSpeed")}
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1431,13 +1447,13 @@ export default function TimelineEditor({
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
<span>Pan</span>
|
||||
<span>{t("labels.pan")}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
<span>Zoom</span>
|
||||
<span>{t("labels.zoom")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
type I18nNamespace,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/config";
|
||||
import { translate } from "@/i18n/loader";
|
||||
|
||||
type TranslateVars = Record<string, string | number>;
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error("useI18n must be used within <I18nProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useScopedT(namespace: I18nNamespace) {
|
||||
const { locale } = useI18n();
|
||||
return useCallback(
|
||||
(key: string, vars?: TranslateVars): string => translate(locale, namespace, key, vars),
|
||||
[locale, namespace],
|
||||
);
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored && isSupportedLocale(stored)) return stored;
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
setLocaleState(newLocale);
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
document.documentElement.lang = newLocale;
|
||||
// Notify Electron main process
|
||||
window.electronAPI?.setLocale?.(newLocale);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
const t = useCallback(
|
||||
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||
const dotIndex = qualifiedKey.indexOf(".");
|
||||
if (dotIndex === -1) return qualifiedKey;
|
||||
const namespace = qualifiedKey.slice(0, dotIndex) as I18nNamespace;
|
||||
const key = qualifiedKey.slice(dotIndex + 1);
|
||||
return translate(locale, namespace, key, vars);
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
@@ -80,6 +81,7 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
|
||||
}
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const t = useScopedT("editor");
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
@@ -152,26 +154,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
setWebcamEnabledState(false);
|
||||
const setWebcamEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
setWebcamEnabledState(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessResult = await requestCameraAccess();
|
||||
if (!accessResult.success) {
|
||||
toast.error(t("recording.failedCameraAccess"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!accessResult.granted) {
|
||||
toast.error(t("recording.cameraBlocked"));
|
||||
return false;
|
||||
}
|
||||
|
||||
setWebcamEnabledState(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessResult = await requestCameraAccess();
|
||||
if (!accessResult.success) {
|
||||
toast.error("Failed to request camera access.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!accessResult.granted) {
|
||||
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setWebcamEnabledState(true);
|
||||
return true;
|
||||
}, []);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const finalizeRecording = useCallback(
|
||||
(
|
||||
@@ -332,7 +337,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
try {
|
||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||
if (!selectedSource) {
|
||||
alert("Please select a source to record");
|
||||
alert(t("recording.selectSource"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,7 +367,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error("System audio not available. Recording without system audio.");
|
||||
toast.error(t("recording.systemAudioUnavailable"));
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
@@ -395,7 +400,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
});
|
||||
} catch (audioError) {
|
||||
console.warn("Failed to get microphone access:", audioError);
|
||||
toast.error("Microphone access denied. Recording will continue without audio.");
|
||||
toast.error(t("recording.microphoneDenied"));
|
||||
setMicrophoneEnabled(false);
|
||||
}
|
||||
}
|
||||
@@ -417,7 +422,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamStream.current = null;
|
||||
}
|
||||
setWebcamEnabledState(false);
|
||||
toast.error("Camera access denied. Recording will continue without webcam.");
|
||||
toast.error(t("recording.cameraDenied"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +537,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
console.error("Failed to start recording:", error);
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
||||
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
||||
toast.error("Recording permission denied. Please allow screen recording.");
|
||||
toast.error(t("recording.permissionDenied"));
|
||||
} else {
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
"editor",
|
||||
"launch",
|
||||
"settings",
|
||||
"shortcuts",
|
||||
"timeline",
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||
|
||||
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
|
||||
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||
string,
|
||||
{ default: MessageMap }
|
||||
>;
|
||||
|
||||
const messages: Record<string, Record<string, MessageMap>> = {};
|
||||
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
// path looks like "./locales/en/common.json"
|
||||
const parts = path.replace("./locales/", "").replace(".json", "").split("/");
|
||||
const locale = parts[0];
|
||||
const namespace = parts[1];
|
||||
if (!messages[locale]) messages[locale] = {};
|
||||
messages[locale][namespace] = mod.default;
|
||||
}
|
||||
|
||||
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<string, unknown>)[key];
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
function interpolate(str: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return str;
|
||||
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
||||
}
|
||||
|
||||
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||
return messages[locale]?.[namespace] ?? {};
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
|
||||
}
|
||||
|
||||
export function getLocaleShort(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
locale: Locale,
|
||||
namespace: I18nNamespace,
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const value =
|
||||
getMessageValue(messages[locale]?.[namespace], key) ??
|
||||
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
|
||||
if (value == null) return `${namespace}.${key}`;
|
||||
return interpolate(value, vars);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"close": "Close",
|
||||
"share": "Share",
|
||||
"done": "Done",
|
||||
"open": "Open",
|
||||
"upload": "Upload",
|
||||
"export": "Export",
|
||||
"file": "File",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"window": "Window",
|
||||
"quit": "Quit",
|
||||
"stopRecording": "Stop Recording"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Play",
|
||||
"pause": "Pause"
|
||||
},
|
||||
"locale": {
|
||||
"name": "English",
|
||||
"short": "EN"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Export Complete",
|
||||
"yourFormatReady": "Your {{format}} is ready",
|
||||
"showInFolder": "Show in Folder",
|
||||
"finalizingVideo": "Finalizing video export...",
|
||||
"compilingGifProgress": "Compiling GIF... {{progress}}%",
|
||||
"compilingGifWait": "Compiling GIF... This may take a while",
|
||||
"takeMoment": "This may take a moment...",
|
||||
"failed": "Export Failed",
|
||||
"tryAgain": "Please try again",
|
||||
"finalizingVideoTitle": "Finalizing Video",
|
||||
"compilingGif": "Compiling GIF",
|
||||
"exportingFormat": "Exporting {{format}}",
|
||||
"compiling": "Compiling",
|
||||
"renderingFrames": "Rendering Frames",
|
||||
"processing": "Processing...",
|
||||
"finalizing": "Finalizing...",
|
||||
"compilingStatus": "Compiling...",
|
||||
"status": "Status",
|
||||
"format": "Format",
|
||||
"frames": "Frames",
|
||||
"cancelExport": "Cancel Export",
|
||||
"savedSuccessfully": "{{format}} saved successfully!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "How trimming works",
|
||||
"title": "How Trimming Works",
|
||||
"description": "Understanding how to cut out unwanted parts of your video.",
|
||||
"explanation": "The Trim tool works by defining the segments you want to",
|
||||
"explanationRemove": "remove",
|
||||
"explanationCovered": "covered",
|
||||
"explanationEnd": "by a red trim segment will be cut out when you export.",
|
||||
"visualExample": "Visual Example",
|
||||
"removed": "REMOVED",
|
||||
"kept": "Kept",
|
||||
"part1": "Part 1",
|
||||
"part2": "Part 2",
|
||||
"part3": "Part 3",
|
||||
"finalVideo": "Final Video",
|
||||
"step1Title": "1. Add Trim",
|
||||
"step1Description": "Press T or click the scissors icon to mark a section for removal.",
|
||||
"step2Title": "2. Adjust",
|
||||
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes.",
|
||||
"detail": "Do you want to save your project before closing?",
|
||||
"saveAndClose": "Save & Close",
|
||||
"discardAndClose": "Discard & Close",
|
||||
"loadProject": "Load Project…",
|
||||
"saveProject": "Save Project…",
|
||||
"saveProjectAs": "Save Project As…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Save Exported GIF",
|
||||
"saveVideo": "Save Exported Video",
|
||||
"selectVideo": "Select Video File",
|
||||
"saveProject": "Save OpenScreen Project",
|
||||
"openProject": "Open OpenScreen Project",
|
||||
"gifImage": "GIF Image",
|
||||
"mp4Video": "MP4 Video",
|
||||
"videoFiles": "Video Files",
|
||||
"openscreenProject": "OpenScreen Project",
|
||||
"allFiles": "All Files"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "No video loaded",
|
||||
"videoNotReady": "Video not ready",
|
||||
"unableToDetermineSourcePath": "Unable to determine source video path",
|
||||
"failedToSaveGif": "Failed to save GIF",
|
||||
"gifExportFailed": "GIF export failed",
|
||||
"failedToSaveVideo": "Failed to save video",
|
||||
"exportFailed": "Export failed",
|
||||
"exportFailedWithError": "Export failed: {{error}}",
|
||||
"failedToSaveExport": "Failed to save export",
|
||||
"failedToSaveExportedVideo": "Failed to save exported video",
|
||||
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Export canceled",
|
||||
"exportedSuccessfully": "{{format}} exported successfully"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Project save canceled",
|
||||
"failedToSave": "Failed to save project",
|
||||
"savedTo": "Project saved to {{path}}",
|
||||
"failedToLoad": "Failed to load project",
|
||||
"invalidFormat": "Invalid project file format",
|
||||
"loadedFrom": "Project loaded from {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Failed to request camera access.",
|
||||
"cameraBlocked": "Camera access is blocked. Enable it in system settings to use the webcam.",
|
||||
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
|
||||
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
|
||||
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
|
||||
"permissionDenied": "Recording permission denied. Please allow screen recording."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Hide HUD",
|
||||
"closeApp": "Close App",
|
||||
"restartRecording": "Restart recording",
|
||||
"openVideoFile": "Open video file",
|
||||
"openProject": "Open project"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Enable system audio",
|
||||
"disableSystemAudio": "Disable system audio",
|
||||
"enableMicrophone": "Enable microphone",
|
||||
"disableMicrophone": "Disable microphone"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Enable webcam",
|
||||
"disableWebcam": "Disable webcam"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Loading sources...",
|
||||
"screens": "Screens ({{count}})",
|
||||
"windows": "Windows ({{count}})",
|
||||
"defaultSourceName": "Screen"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Please select a source to record"
|
||||
},
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Zoom Level",
|
||||
"selectRegion": "Select a zoom region to adjust",
|
||||
"deleteZoom": "Delete Zoom"
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"selectRegion": "Select a speed region to adjust",
|
||||
"deleteRegion": "Delete Speed Region"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Delete Trim Region"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
"preset": "Preset",
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Effects",
|
||||
"blurBg": "Blur BG",
|
||||
"motionBlur": "Motion Blur",
|
||||
"off": "off",
|
||||
"shadow": "Shadow",
|
||||
"roundness": "Roundness",
|
||||
"padding": "Padding"
|
||||
},
|
||||
"background": {
|
||||
"title": "Background",
|
||||
"image": "Image",
|
||||
"color": "Color",
|
||||
"gradient": "Gradient",
|
||||
"uploadCustom": "Upload Custom",
|
||||
"gradientLabel": "Gradient {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Crop",
|
||||
"cropVideo": "Crop Video",
|
||||
"dragInstruction": "Drag on each side to adjust the crop area",
|
||||
"ratio": "Ratio",
|
||||
"free": "Free",
|
||||
"done": "Done",
|
||||
"lockAspectRatio": "Lock aspect ratio",
|
||||
"unlockAspectRatio": "Unlock aspect ratio"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 Video",
|
||||
"mp4Description": "High quality video file",
|
||||
"gifAnimation": "GIF Animation",
|
||||
"gifDescription": "Animated image for sharing"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Export Quality",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF Frame Rate",
|
||||
"size": "GIF Size",
|
||||
"loop": "Loop GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "Save Project",
|
||||
"load": "Load Project"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Export Video",
|
||||
"gifButton": "Export GIF",
|
||||
"chooseSaveLocation": "Choose Save Location"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Report Bug",
|
||||
"starOnGithub": "Star on GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Invalid file type",
|
||||
"jpgOnly": "Please upload a JPG or JPEG image file.",
|
||||
"uploadSuccess": "Custom image uploaded successfully!",
|
||||
"failedToUpload": "Failed to upload image",
|
||||
"errorReading": "There was an error reading the file."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Annotation Settings",
|
||||
"active": "Active",
|
||||
"typeText": "Text",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Arrow",
|
||||
"textContent": "Text Content",
|
||||
"textPlaceholder": "Enter your text...",
|
||||
"fontStyle": "Font Style",
|
||||
"selectStyle": "Select style",
|
||||
"size": "Size",
|
||||
"customFonts": "Custom Fonts",
|
||||
"textColor": "Text Color",
|
||||
"background": "Background",
|
||||
"none": "None",
|
||||
"color": "Color",
|
||||
"clearBackground": "Clear Background",
|
||||
"uploadImage": "Upload Image",
|
||||
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"deleteAnnotation": "Delete Annotation",
|
||||
"shortcutsAndTips": "Shortcuts & Tips",
|
||||
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||
"tipTabCycle": "Use Tab to cycle through overlapping items.",
|
||||
"tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
|
||||
"invalidImageType": "Invalid file type",
|
||||
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
|
||||
"imageUploadSuccess": "Image uploaded successfully!",
|
||||
"failedImageUpload": "Failed to upload image"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Classic",
|
||||
"editor": "Editor",
|
||||
"strong": "Strong",
|
||||
"typewriter": "Typewriter",
|
||||
"deco": "Deco",
|
||||
"simple": "Simple",
|
||||
"modern": "Modern",
|
||||
"clean": "Clean"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Add Google Font",
|
||||
"urlLabel": "Google Fonts Import URL",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
|
||||
"nameLabel": "Display Name",
|
||||
"namePlaceholder": "My Custom Font",
|
||||
"nameHelp": "This is how the font will appear in the font selector",
|
||||
"addButton": "Add Font",
|
||||
"addingButton": "Adding...",
|
||||
"errorEmptyUrl": "Please enter a Google Fonts import URL",
|
||||
"errorInvalidUrl": "Please enter a valid Google Fonts URL",
|
||||
"errorEmptyName": "Please enter a font name",
|
||||
"errorExtractFailed": "Could not extract font family from URL",
|
||||
"successMessage": "Font \"{{fontName}}\" added successfully",
|
||||
"failedToAdd": "Failed to add font",
|
||||
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
|
||||
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "Keyboard Shortcuts",
|
||||
"customize": "Customize",
|
||||
"configurable": "Configurable",
|
||||
"fixed": "Fixed",
|
||||
"pressKey": "Press a key…",
|
||||
"clickToChange": "Click to change",
|
||||
"pressEscToCancel": "Press Esc to cancel",
|
||||
"helpText": "Click a shortcut then press the new key combination. Press Esc to cancel.",
|
||||
"resetToDefaults": "Reset to defaults",
|
||||
"alreadyUsedBy": "Already used by {{action}}",
|
||||
"swap": "Swap",
|
||||
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
|
||||
"savedToast": "Keyboard shortcuts saved",
|
||||
"resetToast": "Reset to default shortcuts — click Save to apply",
|
||||
"actions": {
|
||||
"addZoom": "Add Zoom",
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
"addAnnotation": "Add Annotation",
|
||||
"addKeyframe": "Add Keyframe",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"playPause": "Play / Pause"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"cycleAnnotationsForward": "Cycle Annotations Forward",
|
||||
"cycleAnnotationsBackward": "Cycle Annotations Backward",
|
||||
"deleteSelectedAlt": "Delete Selected (alt)",
|
||||
"panTimeline": "Pan Timeline",
|
||||
"zoomTimeline": "Zoom Timeline"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Add Zoom (Z)",
|
||||
"suggestZooms": "Suggest Zooms from Cursor",
|
||||
"addTrim": "Add Trim (T)",
|
||||
"addAnnotation": "Add Annotation (A)",
|
||||
"addSpeed": "Add Speed (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Press Z to add zoom",
|
||||
"pressTrim": "Press T to add trim",
|
||||
"pressAnnotation": "Press A to add annotation",
|
||||
"pressSpeed": "Press S to add speed"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Pan",
|
||||
"zoom": "Zoom",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Trim {{index}}",
|
||||
"speedItem": "Speed {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Empty text"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No Video Loaded",
|
||||
"dragAndDrop": "Drag and drop a video to start editing"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Cannot place zoom here",
|
||||
"zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
|
||||
"zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
|
||||
"noCursorTelemetry": "No cursor telemetry available",
|
||||
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
|
||||
"noUsableTelemetry": "No usable cursor telemetry",
|
||||
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
|
||||
"noDwellMoments": "No clear cursor dwell moments found",
|
||||
"noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
|
||||
"noAutoZoomSlots": "No auto-zoom slots available",
|
||||
"noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
|
||||
"cannotPlaceTrim": "Cannot place trim here",
|
||||
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
|
||||
"cannotPlaceSpeed": "Cannot place speed here",
|
||||
"speedExistsAtLocation": "Speed region already exists at this location or not enough space available."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
|
||||
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar",
|
||||
"close": "Cerrar",
|
||||
"share": "Compartir",
|
||||
"done": "Listo",
|
||||
"open": "Abrir",
|
||||
"upload": "Subir",
|
||||
"export": "Exportar",
|
||||
"file": "Archivo",
|
||||
"edit": "Editar",
|
||||
"view": "Vista",
|
||||
"window": "Ventana",
|
||||
"quit": "Salir",
|
||||
"stopRecording": "Detener grabación"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Reproducir",
|
||||
"pause": "Pausar"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Español",
|
||||
"short": "ES"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Exportación completada",
|
||||
"yourFormatReady": "Tu {{format}} está listo",
|
||||
"showInFolder": "Mostrar en carpeta",
|
||||
"finalizingVideo": "Finalizando exportación de video...",
|
||||
"compilingGifProgress": "Compilando GIF... {{progress}}%",
|
||||
"compilingGifWait": "Compilando GIF... Esto puede tardar un rato",
|
||||
"takeMoment": "Esto puede tardar un momento...",
|
||||
"failed": "La exportación falló",
|
||||
"tryAgain": "Por favor intenta de nuevo",
|
||||
"finalizingVideoTitle": "Finalizando video",
|
||||
"compilingGif": "Compilando GIF",
|
||||
"exportingFormat": "Exportando {{format}}",
|
||||
"compiling": "Compilando",
|
||||
"renderingFrames": "Renderizando cuadros",
|
||||
"processing": "Procesando...",
|
||||
"finalizing": "Finalizando...",
|
||||
"compilingStatus": "Compilando...",
|
||||
"status": "Estado",
|
||||
"format": "Formato",
|
||||
"frames": "Cuadros",
|
||||
"cancelExport": "Cancelar exportación",
|
||||
"savedSuccessfully": "¡{{format}} guardado exitosamente!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Cómo funciona el recorte",
|
||||
"title": "Cómo funciona el recorte",
|
||||
"description": "Aprende a eliminar las partes no deseadas de tu video.",
|
||||
"explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas",
|
||||
"explanationRemove": "eliminar",
|
||||
"explanationCovered": "cubierto",
|
||||
"explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.",
|
||||
"visualExample": "Ejemplo visual",
|
||||
"removed": "ELIMINADO",
|
||||
"kept": "Conservado",
|
||||
"part1": "Parte 1",
|
||||
"part2": "Parte 2",
|
||||
"part3": "Parte 3",
|
||||
"finalVideo": "Video final",
|
||||
"step1Title": "1. Agregar recorte",
|
||||
"step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
|
||||
"step2Title": "2. Ajustar",
|
||||
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Cambios sin guardar",
|
||||
"message": "Tienes cambios sin guardar.",
|
||||
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
|
||||
"saveAndClose": "Guardar y cerrar",
|
||||
"discardAndClose": "Descartar y cerrar",
|
||||
"loadProject": "Cargar proyecto…",
|
||||
"saveProject": "Guardar proyecto…",
|
||||
"saveProjectAs": "Guardar proyecto como…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Guardar GIF exportado",
|
||||
"saveVideo": "Guardar video exportado",
|
||||
"selectVideo": "Seleccionar archivo de video",
|
||||
"saveProject": "Guardar proyecto OpenScreen",
|
||||
"openProject": "Abrir proyecto OpenScreen",
|
||||
"gifImage": "Imagen GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"videoFiles": "Archivos de video",
|
||||
"openscreenProject": "Proyecto OpenScreen",
|
||||
"allFiles": "Todos los archivos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "No hay video cargado",
|
||||
"videoNotReady": "El video no está listo",
|
||||
"unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
|
||||
"failedToSaveGif": "Error al guardar el GIF",
|
||||
"gifExportFailed": "La exportación de GIF falló",
|
||||
"failedToSaveVideo": "Error al guardar el video",
|
||||
"exportFailed": "La exportación falló",
|
||||
"exportFailedWithError": "La exportación falló: {{error}}",
|
||||
"failedToSaveExport": "Error al guardar la exportación",
|
||||
"failedToSaveExportedVideo": "Error al guardar el video exportado",
|
||||
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Exportación cancelada",
|
||||
"exportedSuccessfully": "{{format}} exportado exitosamente"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Guardado de proyecto cancelado",
|
||||
"failedToSave": "Error al guardar el proyecto",
|
||||
"savedTo": "Proyecto guardado en {{path}}",
|
||||
"failedToLoad": "Error al cargar el proyecto",
|
||||
"invalidFormat": "Formato de archivo de proyecto no válido",
|
||||
"loadedFrom": "Proyecto cargado desde {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Error al solicitar acceso a la cámara.",
|
||||
"cameraBlocked": "El acceso a la cámara está bloqueado. Actívalo en la configuración del sistema para usar la cámara web.",
|
||||
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
|
||||
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
|
||||
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
|
||||
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Ocultar HUD",
|
||||
"closeApp": "Cerrar aplicación",
|
||||
"restartRecording": "Reiniciar grabación",
|
||||
"openVideoFile": "Abrir archivo de video",
|
||||
"openProject": "Abrir proyecto"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Activar audio del sistema",
|
||||
"disableSystemAudio": "Desactivar audio del sistema",
|
||||
"enableMicrophone": "Activar micrófono",
|
||||
"disableMicrophone": "Desactivar micrófono"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Activar cámara web",
|
||||
"disableWebcam": "Desactivar cámara web"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Cargando fuentes...",
|
||||
"screens": "Pantallas ({{count}})",
|
||||
"windows": "Ventanas ({{count}})",
|
||||
"defaultSourceName": "Pantalla"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Por favor selecciona una fuente para grabar"
|
||||
},
|
||||
"language": "Idioma"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Nivel de zoom",
|
||||
"selectRegion": "Selecciona una región de zoom para ajustar",
|
||||
"deleteZoom": "Eliminar zoom"
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Velocidad de reproducción",
|
||||
"selectRegion": "Selecciona una región de velocidad para ajustar",
|
||||
"deleteRegion": "Eliminar región de velocidad"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Eliminar región de recorte"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño",
|
||||
"preset": "Predefinido",
|
||||
"selectPreset": "Seleccionar predefinido",
|
||||
"pictureInPicture": "Imagen en imagen",
|
||||
"verticalStack": "Apilado vertical"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
"blurBg": "Desenfocar fondo",
|
||||
"motionBlur": "Desenfoque de movimiento",
|
||||
"off": "desactivado",
|
||||
"shadow": "Sombra",
|
||||
"roundness": "Redondez",
|
||||
"padding": "Relleno"
|
||||
},
|
||||
"background": {
|
||||
"title": "Fondo",
|
||||
"image": "Imagen",
|
||||
"color": "Color",
|
||||
"gradient": "Degradado",
|
||||
"uploadCustom": "Subir personalizado",
|
||||
"gradientLabel": "Degradado {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Recortar",
|
||||
"cropVideo": "Recortar video",
|
||||
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
|
||||
"ratio": "Proporción",
|
||||
"free": "Libre",
|
||||
"done": "Listo",
|
||||
"lockAspectRatio": "Bloquear relación de aspecto",
|
||||
"unlockAspectRatio": "Desbloquear relación de aspecto"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"mp4Description": "Archivo de video de alta calidad",
|
||||
"gifAnimation": "Animación GIF",
|
||||
"gifDescription": "Imagen animada para compartir"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Calidad de exportación",
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Velocidad de cuadros del GIF",
|
||||
"size": "Tamaño del GIF",
|
||||
"loop": "Repetir GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "Guardar proyecto",
|
||||
"load": "Cargar proyecto"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exportar video",
|
||||
"gifButton": "Exportar GIF",
|
||||
"chooseSaveLocation": "Elegir ubicación de guardado"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Reportar error",
|
||||
"starOnGithub": "Dar estrella en GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Tipo de archivo no válido",
|
||||
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
|
||||
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
|
||||
"failedToUpload": "Error al subir la imagen",
|
||||
"errorReading": "Hubo un error al leer el archivo."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Configuración de anotaciones",
|
||||
"active": "Activo",
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagen",
|
||||
"typeArrow": "Flecha",
|
||||
"textContent": "Contenido de texto",
|
||||
"textPlaceholder": "Escribe tu texto...",
|
||||
"fontStyle": "Estilo de fuente",
|
||||
"selectStyle": "Seleccionar estilo",
|
||||
"size": "Tamaño",
|
||||
"customFonts": "Fuentes personalizadas",
|
||||
"textColor": "Color de texto",
|
||||
"background": "Fondo",
|
||||
"none": "Ninguno",
|
||||
"color": "Color",
|
||||
"clearBackground": "Quitar fondo",
|
||||
"uploadImage": "Subir imagen",
|
||||
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"deleteAnnotation": "Eliminar anotación",
|
||||
"shortcutsAndTips": "Atajos y consejos",
|
||||
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
|
||||
"tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
|
||||
"invalidImageType": "Tipo de archivo no válido",
|
||||
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
|
||||
"imageUploadSuccess": "¡Imagen subida exitosamente!",
|
||||
"failedImageUpload": "Error al subir la imagen"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Clásico",
|
||||
"editor": "Editor",
|
||||
"strong": "Fuerte",
|
||||
"typewriter": "Máquina de escribir",
|
||||
"deco": "Deco",
|
||||
"simple": "Simple",
|
||||
"modern": "Moderno",
|
||||
"clean": "Limpio"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Agregar fuente de Google",
|
||||
"urlLabel": "URL de importación de Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
|
||||
"nameLabel": "Nombre para mostrar",
|
||||
"namePlaceholder": "Mi fuente personalizada",
|
||||
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
|
||||
"addButton": "Agregar fuente",
|
||||
"addingButton": "Agregando...",
|
||||
"errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
|
||||
"errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
|
||||
"errorEmptyName": "Por favor ingresa un nombre de fuente",
|
||||
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
|
||||
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
|
||||
"failedToAdd": "Error al agregar la fuente",
|
||||
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
|
||||
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
|
||||
},
|
||||
"language": {
|
||||
"title": "Idioma"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "Atajos de teclado",
|
||||
"customize": "Personalizar",
|
||||
"configurable": "Configurables",
|
||||
"fixed": "Fijos",
|
||||
"pressKey": "Presiona una tecla…",
|
||||
"clickToChange": "Haz clic para cambiar",
|
||||
"pressEscToCancel": "Presiona Esc para cancelar",
|
||||
"helpText": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
|
||||
"resetToDefaults": "Restablecer valores predeterminados",
|
||||
"alreadyUsedBy": "Ya está en uso por {{action}}",
|
||||
"swap": "Intercambiar",
|
||||
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
|
||||
"savedToast": "Atajos de teclado guardados",
|
||||
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
|
||||
"actions": {
|
||||
"addZoom": "Agregar zoom",
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
"addAnnotation": "Agregar anotación",
|
||||
"addKeyframe": "Agregar fotograma clave",
|
||||
"deleteSelected": "Eliminar seleccionado",
|
||||
"playPause": "Reproducir / Pausar"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Deshacer",
|
||||
"redo": "Rehacer",
|
||||
"cycleAnnotationsForward": "Recorrer anotaciones hacia adelante",
|
||||
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
|
||||
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
|
||||
"panTimeline": "Desplazar línea de tiempo",
|
||||
"zoomTimeline": "Zoom en línea de tiempo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Agregar zoom (Z)",
|
||||
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||
"addTrim": "Agregar recorte (T)",
|
||||
"addAnnotation": "Agregar anotación (A)",
|
||||
"addSpeed": "Agregar velocidad (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Presiona Z para agregar zoom",
|
||||
"pressTrim": "Presiona T para agregar recorte",
|
||||
"pressAnnotation": "Presiona A para agregar anotación",
|
||||
"pressSpeed": "Presiona S para agregar velocidad"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Desplazar",
|
||||
"zoom": "Zoom",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Recorte {{index}}",
|
||||
"speedItem": "Velocidad {{index}}",
|
||||
"annotationItem": "Anotación",
|
||||
"imageItem": "Imagen",
|
||||
"emptyText": "Texto vacío"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No hay video cargado",
|
||||
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "No se puede colocar el zoom aquí",
|
||||
"zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
|
||||
"zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
|
||||
"noCursorTelemetry": "No hay telemetría de cursor disponible",
|
||||
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
|
||||
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
|
||||
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
|
||||
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
|
||||
"noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
|
||||
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
|
||||
"noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
|
||||
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
|
||||
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
|
||||
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
|
||||
"speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
|
||||
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"close": "关闭",
|
||||
"share": "分享",
|
||||
"done": "完成",
|
||||
"open": "打开",
|
||||
"upload": "上传",
|
||||
"export": "导出",
|
||||
"file": "文件",
|
||||
"edit": "编辑",
|
||||
"view": "视图",
|
||||
"window": "窗口",
|
||||
"quit": "退出",
|
||||
"stopRecording": "停止录制"
|
||||
},
|
||||
"playback": {
|
||||
"play": "播放",
|
||||
"pause": "暂停"
|
||||
},
|
||||
"locale": {
|
||||
"name": "中文",
|
||||
"short": "中文"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "导出完成",
|
||||
"yourFormatReady": "您的 {{format}} 已准备就绪",
|
||||
"showInFolder": "在文件夹中显示",
|
||||
"finalizingVideo": "正在完成视频导出...",
|
||||
"compilingGifProgress": "正在编译 GIF... {{progress}}%",
|
||||
"compilingGifWait": "正在编译 GIF... 这可能需要一些时间",
|
||||
"takeMoment": "这可能需要一点时间...",
|
||||
"failed": "导出失败",
|
||||
"tryAgain": "请重试",
|
||||
"finalizingVideoTitle": "正在完成视频",
|
||||
"compilingGif": "正在编译 GIF",
|
||||
"exportingFormat": "正在导出 {{format}}",
|
||||
"compiling": "编译中",
|
||||
"renderingFrames": "渲染帧",
|
||||
"processing": "处理中...",
|
||||
"finalizing": "正在完成...",
|
||||
"compilingStatus": "编译中...",
|
||||
"status": "状态",
|
||||
"format": "格式",
|
||||
"frames": "帧",
|
||||
"cancelExport": "取消导出",
|
||||
"savedSuccessfully": "{{format}} 保存成功!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "剪辑功能说明",
|
||||
"title": "剪辑功能说明",
|
||||
"description": "了解如何剪掉视频中不需要的部分。",
|
||||
"explanation": "剪辑工具通过定义您要",
|
||||
"explanationRemove": "移除",
|
||||
"explanationCovered": "覆盖",
|
||||
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
|
||||
"visualExample": "示例演示",
|
||||
"removed": "已移除",
|
||||
"kept": "保留",
|
||||
"part1": "第 1 部分",
|
||||
"part2": "第 2 部分",
|
||||
"part3": "第 3 部分",
|
||||
"finalVideo": "最终视频",
|
||||
"step1Title": "1. 添加剪辑",
|
||||
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
|
||||
"step2Title": "2. 调整",
|
||||
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "未保存的更改",
|
||||
"message": "您有未保存的更改。",
|
||||
"detail": "是否在关闭前保存项目?",
|
||||
"saveAndClose": "保存并关闭",
|
||||
"discardAndClose": "放弃并关闭",
|
||||
"loadProject": "加载项目…",
|
||||
"saveProject": "保存项目…",
|
||||
"saveProjectAs": "项目另存为…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "保存导出的 GIF",
|
||||
"saveVideo": "保存导出的视频",
|
||||
"selectVideo": "选择视频文件",
|
||||
"saveProject": "保存 OpenScreen 项目",
|
||||
"openProject": "打开 OpenScreen 项目",
|
||||
"gifImage": "GIF 图片",
|
||||
"mp4Video": "MP4 视频",
|
||||
"videoFiles": "视频文件",
|
||||
"openscreenProject": "OpenScreen 项目",
|
||||
"allFiles": "所有文件"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "未加载视频",
|
||||
"videoNotReady": "视频未就绪",
|
||||
"unableToDetermineSourcePath": "无法确定源视频路径",
|
||||
"failedToSaveGif": "保存 GIF 失败",
|
||||
"gifExportFailed": "GIF 导出失败",
|
||||
"failedToSaveVideo": "保存视频失败",
|
||||
"exportFailed": "导出失败",
|
||||
"exportFailedWithError": "导出失败:{{error}}",
|
||||
"failedToSaveExport": "保存导出文件失败",
|
||||
"failedToSaveExportedVideo": "保存导出的视频失败",
|
||||
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "导出已取消",
|
||||
"exportedSuccessfully": "{{format}} 导出成功"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "项目保存已取消",
|
||||
"failedToSave": "保存项目失败",
|
||||
"savedTo": "项目已保存至 {{path}}",
|
||||
"failedToLoad": "加载项目失败",
|
||||
"invalidFormat": "无效的项目文件格式",
|
||||
"loadedFrom": "项目已从 {{path}} 加载"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "请求摄像头权限失败。",
|
||||
"cameraBlocked": "摄像头权限已被阻止。请在系统设置中启用以使用摄像头。",
|
||||
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
|
||||
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
|
||||
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "隐藏控制面板",
|
||||
"closeApp": "关闭应用",
|
||||
"restartRecording": "重新开始录制",
|
||||
"openVideoFile": "打开视频文件",
|
||||
"openProject": "打开项目"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableSystemAudio": "禁用系统音频",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"disableMicrophone": "禁用麦克风"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "启用摄像头",
|
||||
"disableWebcam": "禁用摄像头"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在加载源...",
|
||||
"screens": "屏幕 ({{count}})",
|
||||
"windows": "窗口 ({{count}})",
|
||||
"defaultSourceName": "屏幕"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "请选择要录制的源"
|
||||
},
|
||||
"language": "语言"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "缩放级别",
|
||||
"selectRegion": "选择要调整的缩放区域",
|
||||
"deleteZoom": "删除缩放"
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "选择要调整的速度区域",
|
||||
"deleteRegion": "删除速度区域"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "删除剪辑区域"
|
||||
},
|
||||
"layout": {
|
||||
"title": "布局",
|
||||
"preset": "预设",
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
"blurBg": "模糊背景",
|
||||
"motionBlur": "运动模糊",
|
||||
"off": "关",
|
||||
"shadow": "阴影",
|
||||
"roundness": "圆角",
|
||||
"padding": "内边距"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
"image": "图片",
|
||||
"color": "颜色",
|
||||
"gradient": "渐变",
|
||||
"uploadCustom": "上传自定义",
|
||||
"gradientLabel": "渐变 {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪",
|
||||
"cropVideo": "裁剪视频",
|
||||
"dragInstruction": "拖动每一侧来调整裁剪区域",
|
||||
"ratio": "比例",
|
||||
"free": "自由",
|
||||
"done": "完成",
|
||||
"lockAspectRatio": "锁定宽高比",
|
||||
"unlockAspectRatio": "解锁宽高比"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 视频",
|
||||
"mp4Description": "高质量视频文件",
|
||||
"gifAnimation": "GIF 动画",
|
||||
"gifDescription": "可分享的动态图片"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "导出质量",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 帧率",
|
||||
"size": "GIF 尺寸",
|
||||
"loop": "循环 GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "保存项目",
|
||||
"load": "加载项目"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "导出视频",
|
||||
"gifButton": "导出 GIF",
|
||||
"chooseSaveLocation": "选择保存位置"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "报告错误",
|
||||
"starOnGithub": "在 GitHub 上加星"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "无效的文件类型",
|
||||
"jpgOnly": "请上传 JPG 或 JPEG 格式的图片文件。",
|
||||
"uploadSuccess": "自定义图片上传成功!",
|
||||
"failedToUpload": "上传图片失败",
|
||||
"errorReading": "读取文件时出错。"
|
||||
},
|
||||
"annotation": {
|
||||
"title": "标注设置",
|
||||
"active": "活动",
|
||||
"typeText": "文本",
|
||||
"typeImage": "图片",
|
||||
"typeArrow": "箭头",
|
||||
"textContent": "文本内容",
|
||||
"textPlaceholder": "输入您的文本...",
|
||||
"fontStyle": "字体样式",
|
||||
"selectStyle": "选择样式",
|
||||
"size": "大小",
|
||||
"customFonts": "自定义字体",
|
||||
"textColor": "文本颜色",
|
||||
"background": "背景",
|
||||
"none": "无",
|
||||
"color": "颜色",
|
||||
"clearBackground": "清除背景",
|
||||
"uploadImage": "上传图片",
|
||||
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
|
||||
"arrowDirection": "箭头方向",
|
||||
"strokeWidth": "描边宽度:{{width}}px",
|
||||
"arrowColor": "箭头颜色",
|
||||
"deleteAnnotation": "删除标注",
|
||||
"shortcutsAndTips": "快捷键与提示",
|
||||
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
|
||||
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
|
||||
"tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
|
||||
"invalidImageType": "无效的文件类型",
|
||||
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
|
||||
"imageUploadSuccess": "图片上传成功!",
|
||||
"failedImageUpload": "上传图片失败"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "经典",
|
||||
"editor": "编辑器",
|
||||
"strong": "粗体",
|
||||
"typewriter": "打字机",
|
||||
"deco": "装饰",
|
||||
"simple": "简约",
|
||||
"modern": "现代",
|
||||
"clean": "简洁"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "添加 Google 字体",
|
||||
"urlLabel": "Google Fonts 导入 URL",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
|
||||
"nameLabel": "显示名称",
|
||||
"namePlaceholder": "我的自定义字体",
|
||||
"nameHelp": "这是字体在字体选择器中显示的名称",
|
||||
"addButton": "添加字体",
|
||||
"addingButton": "添加中...",
|
||||
"errorEmptyUrl": "请输入 Google Fonts 导入 URL",
|
||||
"errorInvalidUrl": "请输入有效的 Google Fonts URL",
|
||||
"errorEmptyName": "请输入字体名称",
|
||||
"errorExtractFailed": "无法从 URL 中提取字体系列",
|
||||
"successMessage": "字体 \"{{fontName}}\" 添加成功",
|
||||
"failedToAdd": "添加字体失败",
|
||||
"errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
|
||||
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
|
||||
},
|
||||
"language": {
|
||||
"title": "语言"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "键盘快捷键",
|
||||
"customize": "自定义",
|
||||
"configurable": "可配置",
|
||||
"fixed": "固定",
|
||||
"pressKey": "请按下按键…",
|
||||
"clickToChange": "点击以更改",
|
||||
"pressEscToCancel": "按 Esc 取消",
|
||||
"helpText": "点击一个快捷键,然后按下新的组合键。按 Esc 取消。",
|
||||
"resetToDefaults": "恢复默认设置",
|
||||
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
|
||||
"swap": "交换",
|
||||
"reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。",
|
||||
"savedToast": "键盘快捷键已保存",
|
||||
"resetToast": "已恢复默认快捷键 — 点击保存以应用",
|
||||
"actions": {
|
||||
"addZoom": "添加缩放",
|
||||
"addTrim": "添加剪辑",
|
||||
"addSpeed": "添加速度",
|
||||
"addAnnotation": "添加标注",
|
||||
"addKeyframe": "添加关键帧",
|
||||
"deleteSelected": "删除所选",
|
||||
"playPause": "播放 / 暂停"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"cycleAnnotationsForward": "向前切换标注",
|
||||
"cycleAnnotationsBackward": "向后切换标注",
|
||||
"deleteSelectedAlt": "删除所选(替代)",
|
||||
"panTimeline": "平移时间轴",
|
||||
"zoomTimeline": "缩放时间轴"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "添加缩放 (Z)",
|
||||
"suggestZooms": "根据光标建议缩放",
|
||||
"addTrim": "添加剪辑 (T)",
|
||||
"addAnnotation": "添加标注 (A)",
|
||||
"addSpeed": "添加速度 (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "按 Z 添加缩放",
|
||||
"pressTrim": "按 T 添加剪辑",
|
||||
"pressAnnotation": "按 A 添加标注",
|
||||
"pressSpeed": "按 S 添加速度"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "平移",
|
||||
"zoom": "缩放",
|
||||
"zoomItem": "缩放 {{index}}",
|
||||
"trimItem": "剪辑 {{index}}",
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "标注",
|
||||
"imageItem": "图片",
|
||||
"emptyText": "空文本"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "未加载视频",
|
||||
"dragAndDrop": "拖放视频以开始编辑"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "无法在此处放置缩放",
|
||||
"zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
|
||||
"zoomSuggestionUnavailable": "缩放建议处理器不可用",
|
||||
"noCursorTelemetry": "无可用的光标遥测数据",
|
||||
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
|
||||
"noUsableTelemetry": "无可用的光标遥测数据",
|
||||
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
|
||||
"noDwellMoments": "未找到明确的光标停留时刻",
|
||||
"noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
|
||||
"noAutoZoomSlots": "无可用的自动缩放位置",
|
||||
"noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
|
||||
"cannotPlaceTrim": "无法在此处放置剪辑",
|
||||
"trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
|
||||
"cannotPlaceSpeed": "无法在此处放置速度",
|
||||
"speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。"
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
|
||||
"addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { I18nProvider } from "./contexts/I18nContext";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Vendored
+1
@@ -119,5 +119,6 @@ interface Window {
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
setLocale: (locale: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user