Files
openscreen/src/components/video-editor/SettingsPanel.tsx
T
2026-01-27 11:10:12 +05:30

721 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
"radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )",
"linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )",
"linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )",
"linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )",
"radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )",
"linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )",
"linear-gradient(135deg, #FBC8B4, #2447B1)",
"linear-gradient(109.6deg, #F635A6, #36D860)",
"linear-gradient(90deg, #FF0101, #4DFF01)",
"linear-gradient(315deg, #EC0101, #5044A9)",
"linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)",
"linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)",
"linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)",
"linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)",
"linear-gradient(to right, #4facfe 0%, #00f2fe 100%)",
"linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)",
"linear-gradient(to right, #fa709a 0%, #fee140 100%)",
"linear-gradient(to top, #30cfd0 0%, #330867 100%)",
"linear-gradient(to top, #c471f5 0%, #fa71cd 100%)",
"linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)",
"linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)",
"linear-gradient(to right, #0acffe 0%, #495aff 100%)",
];
interface SettingsPanelProps {
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
motionBlurEnabled?: boolean;
onMotionBlurChange?: (enabled: boolean) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
videoElement?: HTMLVideoElement | null;
exportQuality?: ExportQuality;
onExportQualityChange?: (quality: ExportQuality) => void;
// Export format settings
exportFormat?: ExportFormat;
onExportFormatChange?: (format: ExportFormat) => void;
gifFrameRate?: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
gifLoop?: boolean;
onGifLoopChange?: (loop: boolean) => void;
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationDelete?: (id: string) => void;
}
export default SettingsPanel;
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 1, label: "1.25×" },
{ depth: 2, label: "1.5×" },
{ depth: 3, label: "1.8×" },
{ depth: 4, label: "2.2×" },
{ depth: 5, label: "3.5×" },
{ depth: 6, label: "5×" },
];
export function SettingsPanel({
selected,
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomId,
onZoomDelete,
selectedTrimId,
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
showBlur,
onBlurChange,
motionBlurEnabled = false,
onMotionBlurChange,
borderRadius = 0,
onBorderRadiusChange,
padding = 50,
onPaddingChange,
cropRegion,
onCropChange,
aspectRatio,
videoElement,
exportQuality = 'good',
onExportQualityChange,
exportFormat = 'mp4',
onExportFormatChange,
gifFrameRate = 15,
onGifFrameRateChange,
gifLoop = true,
onGifLoopChange,
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onExport,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
}: SettingsPanelProps) {
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let mounted = true
;(async () => {
try {
const resolved = await Promise.all(WALLPAPER_RELATIVE.map(p => getAssetPath(p)))
if (mounted) setWallpaperPaths(resolved)
} catch (err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map(p => `/${p}`))
}
})()
return () => { mounted = false }
}, [])
const colorPalette = [
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
'#34B27B', '#000000', '#607D8B', '#795548',
];
const [selectedColor, setSelectedColor] = useState('#ADADAD');
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
const handleDeleteClick = () => {
if (selectedZoomId && onZoomDelete) {
onZoomDelete(selectedZoomId);
}
};
const handleTrimDeleteClick = () => {
if (selectedTrimId && onTrimDelete) {
onTrimDelete(selectedTrimId);
}
};
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// 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.',
});
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
setCustomImages(prev => [...prev, dataUrl]);
onWallpaperChange(dataUrl);
toast.success('Custom image uploaded successfully!');
}
};
reader.onerror = () => {
toast.error('Failed to upload image', {
description: 'There was an error reading the file.',
});
};
reader.readAsDataURL(file);
// Reset input so the same file can be selected again
event.target.value = '';
};
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
event.stopPropagation();
setCustomImages(prev => prev.filter(img => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
}
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find(a => a.id === selectedAnnotationId)
: null;
// If an annotation is selected, show annotation settings instead
if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) {
return (
<AnnotationSettingsPanel
annotation={selectedAnnotation}
onContentChange={(content) => onAnnotationContentChange(selectedAnnotation.id, content)}
onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)}
onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)}
onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<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>
<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">
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label}
</span>
)}
<KeyboardShortcutsHelp />
</div>
</div>
<div className="grid grid-cols-6 gap-1.5">
{ZOOM_DEPTH_OPTIONS.map((option) => {
const isActive = selectedZoomDepth === option.depth;
return (
<Button
key={option.depth}
type="button"
disabled={!zoomEnabled}
onClick={() => onZoomDepthChange?.(option.depth)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out",
zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
)}
>
<span className="text-xs font-semibold">{option.label}</span>
</Button>
);
})}
</div>
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a zoom region to adjust</p>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
variant="destructive"
size="sm"
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
</Button>
)}
</div>
{trimEnabled && (
<div className="mb-4">
<Button
onClick={handleTrimDeleteClick}
variant="destructive"
size="sm"
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
</Button>
</div>
)}
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
<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>
</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">Motion Blur</div>
<Switch
checked={motionBlurEnabled}
onCheckedChange={onMotionBlurChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
<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>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
</div>
<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">Shadow</div>
<span className="text-[10px] text-slate-500 font-mono">{Math.round(shadowIntensity * 100)}%</span>
</div>
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
min={0}
max={1}
step={0.01}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</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>
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
</div>
<Slider
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
min={0}
max={16}
step={0.5}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</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>
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
</div>
<Slider
value={[padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
min={0}
max={100}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
</div>
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
variant="outline"
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
</Button>
</AccordionContent>
</AccordionItem>
<AccordionItem value="background" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
<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>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<Tabs defaultValue="image" className="w-full">
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<TabsTrigger 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</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</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</TabsTrigger>
</TabsList>
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
<TabsContent value="image" className="mt-0 space-y-2">
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept=".jpg,.jpeg,image/jpeg"
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
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
</Button>
<div className="grid grid-cols-7 gap-1.5">
{customImages.map((imageUrl, idx) => {
const isSelected = selected === imageUrl;
return (
<div
key={`custom-${idx}`}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm",
isSelected
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }}
onClick={() => onWallpaperChange(imageUrl)}
role="button"
>
<button
onClick={(e) => handleRemoveCustomImage(imageUrl, e)}
className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X className="w-2 h-2 text-white" />
</button>
</div>
);
})}
{(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path) => {
const isSelected = (() => {
if (!selected) return false;
if (selected === path) return true;
try {
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {}
return false;
})();
return (
<div
key={path}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
isSelected
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ backgroundImage: `url(${path})`, backgroundSize: "cover", backgroundPosition: "center" }}
onClick={() => onWallpaperChange(path)}
role="button"
/>
)
})}
</div>
</TabsContent>
<TabsContent value="color" className="mt-0">
<div className="p-1">
<Block
color={selectedColor}
colors={colorPalette}
onChange={(color) => {
setSelectedColor(color.hex);
onWallpaperChange(color.hex);
}}
style={{
width: '100%',
borderRadius: '8px',
}}
/>
</div>
</TabsContent>
<TabsContent value="gradient" className="mt-0">
<div className="grid grid-cols-7 gap-1.5">
{GRADIENTS.map((g, idx) => (
<div
key={g}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
gradient === g
? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
onClick={() => { setGradient(g); onWallpaperChange(g); }}
role="button"
/>
))}
</div>
</TabsContent>
</div>
</Tabs>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{showCropDropdown && cropRegion && onCropChange && (
<>
<div
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
onClick={() => setShowCropDropdown(false)}
/>
<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>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowCropDropdown(false)}
className="hover:bg-white/10 text-slate-400 hover:text-white"
>
<X className="w-5 h-5" />
</Button>
</div>
<CropControl
videoElement={videoElement || null}
cropRegion={cropRegion}
onCropChange={onCropChange}
aspectRatio={aspectRatio}
/>
<div className="mt-6 flex justify-end">
<Button
onClick={() => setShowCropDropdown(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
</div>
</div>
</>
)}
<div className="flex-shrink-0 p-4 pt-3 border-t border-white/5 bg-[#09090b]">
<div className="flex items-center gap-2 mb-3">
<button
onClick={() => onExportFormatChange?.('mp4')}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
exportFormat === 'mp4'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Film className="w-3.5 h-3.5" />
MP4
</button>
<button
onClick={() => onExportFormatChange?.('gif')}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
exportFormat === 'gif'
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
<Image className="w-3.5 h-3.5" />
GIF
</button>
</div>
{exportFormat === 'mp4' && (
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<button
onClick={() => onExportQualityChange?.('medium')}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === 'medium' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
Low
</button>
<button
onClick={() => onExportQualityChange?.('good')}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === 'good' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
Medium
</button>
<button
onClick={() => onExportQualityChange?.('source')}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === 'source' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
High
</button>
</div>
)}
{exportFormat === 'gif' && (
<div className="mb-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-white/5 border border-white/5 p-0.5 grid grid-cols-4 h-7 rounded-lg">
{GIF_FRAME_RATES.map((rate) => (
<button
key={rate.value}
onClick={() => onGifFrameRateChange?.(rate.value)}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
gifFrameRate === rate.value ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
{rate.value}
</button>
))}
</div>
<div className="flex-1 bg-white/5 border border-white/5 p-0.5 grid grid-cols-3 h-7 rounded-lg">
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
<button
key={key}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
gifSizePreset === key ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
{key === 'original' ? 'Orig' : key.charAt(0).toUpperCase() + key.slice(1, 3)}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500">{gifOutputDimensions.width} × {gifOutputDimensions.height}px</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">Loop</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
className="data-[state=checked]:bg-[#34B27B] scale-75"
/>
</div>
</div>
</div>
)}
<Button
type="button"
size="lg"
onClick={onExport}
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'}
</Button>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new/choose');
}}
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
</button>
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen');
}}
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
</button>
</div>
</div>
</div>
);
}