setActiveTab(tabKey)}
key={tabKey}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 58cc5d5b..96166d43 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -350,18 +350,20 @@ export function PreviewPanel() {
style={{
left: `${
50 +
- ((dragState.isDragging && dragState.elementId === element.id
- ? dragState.currentX
- : element.x) /
- canvasSize.width) *
+ (
+ (dragState.isDragging && dragState.elementId === element.id
+ ? dragState.currentX
+ : element.x) / canvasSize.width
+ ) *
100
}%`,
top: `${
50 +
- ((dragState.isDragging && dragState.elementId === element.id
- ? dragState.currentY
- : element.y) /
- canvasSize.height) *
+ (
+ (dragState.isDragging && dragState.elementId === element.id
+ ? dragState.currentY
+ : element.y) / canvasSize.height
+ ) *
100
}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index d4d121d9..542c7734 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -94,9 +94,7 @@ const EditableShortcutKey = ({
variant="text"
size="sm"
className={`inline-flex font-sans text-xs rounded px-2 min-w-6 min-h-6 leading-none items-center justify-center shadow-xs border mr-1 cursor-pointer hover:bg-opacity-80 ${
- isRecording
- ? "border-primary bg-primary/10"
- : "border bg-accent"
+ isRecording ? "border-primary bg-primary/10" : "border bg-accent"
}`}
onClick={handleClick}
title={
diff --git a/apps/web/src/components/panel-preset-selector.tsx b/apps/web/src/components/panel-preset-selector.tsx
new file mode 100644
index 00000000..faec916d
--- /dev/null
+++ b/apps/web/src/components/panel-preset-selector.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { Button } from "./ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
+import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
+
+const PRESET_LABELS: Record
= {
+ default: "Default",
+ media: "Media",
+ inspector: "Inspector",
+ "vertical-preview": "Vertical Preview",
+};
+
+const PRESET_DESCRIPTIONS: Record = {
+ default: "Media, preview, and inspector on top row, timeline on bottom",
+ media: "Full height media on left, preview and inspector on top row",
+ inspector: "Full height inspector on right, media and preview on top row",
+ "vertical-preview": "Full height preview on right for vertical videos",
+};
+
+export function PanelPresetSelector() {
+ const { activePreset, setActivePreset, resetPreset } = usePanelStore();
+
+ const handlePresetChange = (preset: PanelPreset) => {
+ setActivePreset(preset);
+ };
+
+ const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
+ event.stopPropagation();
+ resetPreset(preset);
+ };
+
+ return (
+
+
+
+
+
+
+ Panel Presets
+
+
+ {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
+ handlePresetChange(preset)}
+ className="flex items-start justify-between gap-2 py-2 cursor-pointer"
+ >
+
+
+
+ {PRESET_LABELS[preset]}
+
+ {activePreset === preset && (
+
+ )}
+
+
+ {PRESET_DESCRIPTIONS[preset]}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx
index f6d29f49..55375e33 100644
--- a/apps/web/src/components/ui/editable-timecode.tsx
+++ b/apps/web/src/components/ui/editable-timecode.tsx
@@ -1,137 +1,137 @@
-"use client";
-
-import { useState, useRef, useEffect } from "react";
-import { cn } from "@/lib/utils";
-import { formatTimeCode, parseTimeCode } from "@/lib/time";
-
-interface EditableTimecodeProps {
- time: number;
- duration?: number;
- format?: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
- fps?: number;
- onTimeChange?: (time: number) => void;
- className?: string;
- disabled?: boolean;
-}
-
-export function EditableTimecode({
- time,
- duration,
- format = "HH:MM:SS:FF",
- fps = 30,
- onTimeChange,
- className,
- disabled = false,
-}: EditableTimecodeProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [inputValue, setInputValue] = useState("");
- const [hasError, setHasError] = useState(false);
- const inputRef = useRef(null);
- const enterPressedRef = useRef(false);
-
- const formattedTime = formatTimeCode(time, format, fps);
-
- const startEditing = () => {
- if (disabled) return;
- setIsEditing(true);
- setInputValue(formattedTime);
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const cancelEditing = () => {
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const applyEdit = () => {
- const parsedTime = parseTimeCode(inputValue, format, fps);
-
- if (parsedTime === null) {
- setHasError(true);
- return;
- }
-
- // Clamp time to valid range
- const clampedTime = Math.max(
- 0,
- duration ? Math.min(duration, parsedTime) : parsedTime
- );
-
- onTimeChange?.(clampedTime);
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- e.preventDefault();
- enterPressedRef.current = true;
- applyEdit();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelEditing();
- }
- };
-
- const handleInputChange = (e: React.ChangeEvent) => {
- setInputValue(e.target.value);
- setHasError(false);
- };
-
- const handleBlur = () => {
- // Only apply edit if Enter wasn't pressed (to avoid double processing)
- if (!enterPressedRef.current && isEditing) {
- applyEdit();
- }
- };
-
- // Focus input when entering edit mode
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- if (isEditing) {
- return (
-
- );
- }
-
- return (
-
- {formattedTime}
-
- );
-}
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { formatTimeCode, parseTimeCode } from "@/lib/time";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration?: number;
+ format?: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
+ fps?: number;
+ onTimeChange?: (time: number) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps = 30,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef(null);
+ const enterPressedRef = useRef(false);
+
+ const formattedTime = formatTimeCode(time, format, fps);
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode(inputValue, format, fps);
+
+ if (parsedTime === null) {
+ setHasError(true);
+ return;
+ }
+
+ // Clamp time to valid range
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime
+ );
+
+ onTimeChange?.(clampedTime);
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ // Only apply edit if Enter wasn't pressed (to avoid double processing)
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ // Focus input when entering edit mode
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {formattedTime}
+
+ );
+}
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx
index 6a7677b4..50c7e7cc 100644
--- a/apps/web/src/data/colors/syntax-ui.tsx
+++ b/apps/web/src/data/colors/syntax-ui.tsx
@@ -1,32 +1,32 @@
-// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
-
-export const syntaxUIGradients = [
- // Cyan to Blue gradients
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
- "linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
-
- // Purple gradients
- "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
- "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
-
- // Blue gradients
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
-
- // Green gradients
- "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
- "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
-
- // Red gradient
- "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
-
- // Yellow/Orange gradient
- "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
-
- // Pink gradient
- "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
-
- // Neon radial gradient
- "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
-];
+// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
+
+export const syntaxUIGradients = [
+ // Cyan to Blue gradients
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+ "linear-gradient(to right, #bfdbfe, #a5f3fc)",
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+
+ // Purple gradients
+ "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
+ "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
+
+ // Blue gradients
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+
+ // Green gradients
+ "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
+ "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
+
+ // Red gradient
+ "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
+
+ // Yellow/Orange gradient
+ "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
+
+ // Pink gradient
+ "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
+
+ // Neon radial gradient
+ "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
+];
diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts
index ebbce3c0..0feda0d9 100644
--- a/apps/web/src/stores/panel-store.ts
+++ b/apps/web/src/stores/panel-store.ts
@@ -1,21 +1,51 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-const DEFAULT_PANEL_SIZES = {
- toolsPanel: 45,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
-} as const;
+export type PanelPreset = "default" | "media" | "inspector" | "vertical-preview";
-interface PanelState {
- // Panel sizes as percentages
+interface PanelSizes {
toolsPanel: number;
previewPanel: number;
propertiesPanel: number;
mainContent: number;
timeline: number;
+}
+
+const PRESET_CONFIGS: Record = {
+ default: {
+ toolsPanel: 25,
+ previewPanel: 50,
+ propertiesPanel: 25,
+ mainContent: 70,
+ timeline: 30,
+ },
+ media: {
+ toolsPanel: 30,
+ previewPanel: 45,
+ propertiesPanel: 25,
+ mainContent: 100,
+ timeline: 25,
+ },
+ inspector: {
+ toolsPanel: 25,
+ previewPanel: 50,
+ propertiesPanel: 25,
+ mainContent: 100,
+ timeline: 25,
+ },
+ "vertical-preview": {
+ toolsPanel: 25,
+ previewPanel: 40,
+ propertiesPanel: 35,
+ mainContent: 100,
+ timeline: 25,
+ },
+};
+
+interface PanelState extends PanelSizes {
+ activePreset: PanelPreset;
+ presetCustomSizes: Record>;
+ resetCounter: number;
mediaViewMode: "grid" | "list";
@@ -26,26 +56,158 @@ interface PanelState {
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
setMediaViewMode: (mode: "grid" | "list") => void;
+
+ // Preset actions
+ setActivePreset: (preset: PanelPreset) => void;
+ resetPreset: (preset: PanelPreset) => void;
+ getCurrentPresetSizes: () => PanelSizes;
}
export const usePanelStore = create()(
persist(
- (set) => ({
- // Default sizes - optimized for responsiveness
- ...DEFAULT_PANEL_SIZES,
+ (set, get) => ({
+ ...PRESET_CONFIGS.default,
+ activePreset: "default" as PanelPreset,
+ presetCustomSizes: {
+ default: {},
+ media: {},
+ inspector: {},
+ "vertical-preview": {},
+ },
+ resetCounter: 0,
mediaViewMode: "grid" as const,
- // Actions
- setToolsPanel: (size) => set({ toolsPanel: size }),
- setPreviewPanel: (size) => set({ previewPanel: size }),
- setPropertiesPanel: (size) => set({ propertiesPanel: size }),
- setMainContent: (size) => set({ mainContent: size }),
- setTimeline: (size) => set({ timeline: size }),
+ setToolsPanel: (size) => {
+ const { activePreset, presetCustomSizes } = get();
+ set({
+ toolsPanel: size,
+ presetCustomSizes: {
+ ...presetCustomSizes,
+ [activePreset]: {
+ ...presetCustomSizes[activePreset],
+ toolsPanel: size,
+ },
+ },
+ });
+ },
+ setPreviewPanel: (size) => {
+ const { activePreset, presetCustomSizes } = get();
+ set({
+ previewPanel: size,
+ presetCustomSizes: {
+ ...presetCustomSizes,
+ [activePreset]: {
+ ...presetCustomSizes[activePreset],
+ previewPanel: size,
+ },
+ },
+ });
+ },
+ setPropertiesPanel: (size) => {
+ const { activePreset, presetCustomSizes } = get();
+ set({
+ propertiesPanel: size,
+ presetCustomSizes: {
+ ...presetCustomSizes,
+ [activePreset]: {
+ ...presetCustomSizes[activePreset],
+ propertiesPanel: size,
+ },
+ },
+ });
+ },
+ setMainContent: (size) => {
+ const { activePreset, presetCustomSizes } = get();
+ set({
+ mainContent: size,
+ presetCustomSizes: {
+ ...presetCustomSizes,
+ [activePreset]: {
+ ...presetCustomSizes[activePreset],
+ mainContent: size,
+ },
+ },
+ });
+ },
+ setTimeline: (size) => {
+ const { activePreset, presetCustomSizes } = get();
+ set({
+ timeline: size,
+ presetCustomSizes: {
+ ...presetCustomSizes,
+ [activePreset]: {
+ ...presetCustomSizes[activePreset],
+ timeline: size,
+ },
+ },
+ });
+ },
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
+
+ setActivePreset: (preset) => {
+ const {
+ activePreset: currentPreset,
+ presetCustomSizes,
+ toolsPanel,
+ previewPanel,
+ propertiesPanel,
+ mainContent,
+ timeline
+ } = get();
+
+ const updatedPresetCustomSizes = {
+ ...presetCustomSizes,
+ [currentPreset]: {
+ toolsPanel,
+ previewPanel,
+ propertiesPanel,
+ mainContent,
+ timeline,
+ },
+ };
+
+ const defaultSizes = PRESET_CONFIGS[preset];
+ const customSizes = updatedPresetCustomSizes[preset] || {};
+ const finalSizes = { ...defaultSizes, ...customSizes };
+
+ set({
+ activePreset: preset,
+ presetCustomSizes: updatedPresetCustomSizes,
+ ...finalSizes,
+ });
+ },
+
+ resetPreset: (preset) => {
+ const { presetCustomSizes, activePreset, resetCounter } = get();
+ const defaultSizes = PRESET_CONFIGS[preset];
+
+ const newPresetCustomSizes = {
+ ...presetCustomSizes,
+ [preset]: {},
+ };
+
+ const updates: Partial = {
+ presetCustomSizes: newPresetCustomSizes,
+ resetCounter: resetCounter + 1,
+ };
+
+ if (preset === activePreset) {
+ Object.assign(updates, defaultSizes);
+ }
+
+ set(updates);
+ },
+
+ getCurrentPresetSizes: () => {
+ const { toolsPanel, previewPanel, propertiesPanel, mainContent, timeline } = get();
+ return { toolsPanel, previewPanel, propertiesPanel, mainContent, timeline };
+ },
}),
{
name: "panel-sizes",
}
)
);
+
+export { PRESET_CONFIGS };