lang support

This commit is contained in:
Siddharth
2026-03-21 18:18:43 -07:00
parent 3d680e8521
commit 4a299063c3
47 changed files with 1979 additions and 331 deletions
+45 -10
View File
@@ -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>
+8 -5
View File
@@ -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>
+30 -20
View File
@@ -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>
+14 -22
View File
@@ -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" />
+66 -54
View File
@@ -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>
+33 -25
View File
@@ -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>
+33 -6
View File
@@ -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>