diff options
| author | enkeii64 <imenkei64@gmail.com> | 2025-08-04 17:38:02 +1000 |
|---|---|---|
| committer | enkeii64 <imenkei64@gmail.com> | 2025-08-04 17:38:02 +1000 |
| commit | 3a6bc6ebf71475a19b9202061ab08f93d1a67b9f (patch) | |
| tree | 3cf00905e560c561ebea05a5b1b8a2bece4c7602 /apps | |
| parent | ae54aff1a9762ceb9e42a534c29c584bbf18d6d5 (diff) | |
| download | opencut-3a6bc6ebf71475a19b9202061ab08f93d1a67b9f.tar.gz | |
feat: add panel presets
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 339 | ||||
| -rw-r--r-- | apps/web/src/components/editor-header.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/editor/audio-waveform.tsx | 10 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/tabbar.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 18 | ||||
| -rw-r--r-- | apps/web/src/components/keyboard-shortcuts-help.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/panel-preset-selector.tsx | 91 | ||||
| -rw-r--r-- | apps/web/src/components/ui/editable-timecode.tsx | 274 | ||||
| -rw-r--r-- | apps/web/src/data/colors/syntax-ui.tsx | 64 | ||||
| -rw-r--r-- | apps/web/src/stores/panel-store.ts | 198 |
10 files changed, 746 insertions, 258 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 03579144..f5e64a93 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -30,6 +30,8 @@ export default function Editor() { setTimeline, propertiesPanel, setPropertiesPanel, + activePreset, + resetCounter, } = usePanelStore(); const { @@ -153,72 +155,301 @@ export default function Editor() { <div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> <EditorHeader /> <div className="flex-1 min-h-0 min-w-0"> - <ResizablePanelGroup - direction="vertical" - className="h-full w-full gap-[0.18rem]" - > - <ResizablePanel - defaultSize={mainContent} - minSize={30} - maxSize={85} - onResize={setMainContent} - className="min-h-0" + + {activePreset === "media" ? ( + <ResizablePanelGroup + key={`media-${activePreset}-${resetCounter}`} + direction="horizontal" + className="h-full w-full gap-[0.18rem] px-3 pb-3" > - {/* Main content area */} - <ResizablePanelGroup - direction="horizontal" - className="h-full w-full gap-[0.19rem] px-3" + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0 rounded-sm" + > + <MediaPanel /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={100 - toolsPanel} + minSize={60} + className="min-w-0 min-h-0" > - {/* Tools Panel */} - <ResizablePanel - defaultSize={toolsPanel} - minSize={15} - maxSize={40} - onResize={setToolsPanel} - className="min-w-0 rounded-sm" + <ResizablePanelGroup + direction="vertical" + className="h-full w-full gap-[0.18rem]" > - <MediaPanel /> - </ResizablePanel> + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem]" + > + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Preview Area */} - <ResizablePanel - defaultSize={previewPanel} - minSize={30} - onResize={setPreviewPanel} - className="min-w-0 min-h-0 flex-1" + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + </ResizablePanelGroup> + ) : activePreset === "inspector" ? ( + <ResizablePanelGroup + key={`inspector-${activePreset}-${resetCounter}`} + direction="horizontal" + className="h-full w-full gap-[0.18rem] px-3 pb-3" + > + <ResizablePanel + defaultSize={100 - propertiesPanel} + minSize={60} + className="min-w-0 min-h-0" + > + <ResizablePanelGroup + direction="vertical" + className="h-full w-full gap-[0.18rem]" > - <PreviewPanel /> - </ResizablePanel> + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem]" + > + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0 rounded-sm" + > + <MediaPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - <ResizablePanel - defaultSize={propertiesPanel} - minSize={15} - maxSize={40} - onResize={setPropertiesPanel} - className="min-w-0" + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + ) : activePreset === "vertical-preview" ? ( + <ResizablePanelGroup + key={`vertical-preview-${activePreset}-${resetCounter}`} + direction="horizontal" + className="h-full w-full gap-[0.18rem] px-3 pb-3" + > + <ResizablePanel + defaultSize={100 - previewPanel} + minSize={60} + className="min-w-0 min-h-0" + > + <ResizablePanelGroup + direction="vertical" + className="h-full w-full gap-[0.18rem]" > - <PropertiesPanel /> - </ResizablePanel> - </ResizablePanelGroup> - </ResizablePanel> + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem]" + > + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0 rounded-sm" + > + <MediaPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Timeline */} - <ResizablePanel - defaultSize={timeline} - minSize={15} - maxSize={70} - onResize={setTimeline} - className="min-h-0 px-3 pb-3" + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0" + > + <PreviewPanel /> + </ResizablePanel> + </ResizablePanelGroup> + ) : ( + <ResizablePanelGroup + key={`default-${activePreset}-${resetCounter}`} + direction="vertical" + className="h-full w-full gap-[0.18rem]" > - <Timeline /> - </ResizablePanel> - </ResizablePanelGroup> + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + {/* Main content area */} + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem] px-3" + > + {/* Tools Panel */} + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0 rounded-sm" + > + <MediaPanel /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + {/* Preview Area */} + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + {/* Timeline */} + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0 px-3 pb-3" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + )} </div> <Onboarding /> </div> diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 77202c36..e197f2a4 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -29,6 +29,7 @@ import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { useTheme } from "next-themes"; import { usePlaybackStore } from "@/stores/playback-store"; +import { PanelPresetSelector } from "./panel-preset-selector"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); @@ -147,6 +148,7 @@ export function EditorHeader() { const rightContent = ( <nav className="flex items-center gap-2"> + <PanelPresetSelector /> <KeyboardShortcutsHelp /> <Button size="sm" diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 372a31c5..79bf401d 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -20,7 +20,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ useEffect(() => { let mounted = true; let ws = wavesurfer.current; - + const initWaveSurfer = async () => { if (!waveformRef.current || !audioUrl) return; @@ -90,7 +90,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ const wsToDestroy = ws; // Detach from ref immediately wavesurfer.current = null; - + // Wait a tick to destroy so any pending operations can complete requestAnimationFrame(() => { try { @@ -111,13 +111,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ return () => { // Mark component as unmounted mounted = false; - + // Store reference to current wavesurfer instance const wsToDestroy = wavesurfer.current; - + // Immediately clear the ref to prevent accessing it after unmount wavesurfer.current = null; - + // If we have an instance to clean up, do it safely if (wsToDestroy) { // Delay destruction to avoid race conditions diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index fc4f677d..6fab0abf 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -77,7 +77,9 @@ export function TabBar() { <div className={cn( "flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75", - activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground" + activeTab === tabKey + ? "text-primary !opacity-100" + : "text-muted-foreground" )} onClick={() => 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<PanelPreset, string> = { + default: "Default", + media: "Media", + inspector: "Inspector", + "vertical-preview": "Vertical Preview", +}; + +const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = { + 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-8 px-2 flex items-center gap-1 text-xs" + title="Panel Presets" + > + <LayoutPanelTop className="h-4 w-4" /> + <ChevronDown className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-64"> + <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> + Panel Presets + </div> + <DropdownMenuSeparator /> + {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( + <DropdownMenuItem + key={preset} + onClick={() => handlePresetChange(preset)} + className="flex items-start justify-between gap-2 py-2 cursor-pointer" + > + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm"> + {PRESET_LABELS[preset]} + </span> + {activePreset === preset && ( + <div className="h-2 w-2 rounded-full bg-primary" /> + )} + </div> + <p className="text-xs text-muted-foreground mt-0.5 leading-tight"> + {PRESET_DESCRIPTIONS[preset]} + </p> + </div> + <Button + variant="secondary" + size="icon" + className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100" + onClick={(e) => handleResetPreset(preset, e)} + title={`Reset ${PRESET_LABELS[preset]} preset`} + > + <RotateCcw className="h-3 w-3" /> + </Button> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} 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<HTMLInputElement>(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<HTMLInputElement>) => {
- 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 (
- <input
- ref={inputRef}
- type="text"
- value={inputValue}
- onChange={handleInputChange}
- onKeyDown={handleKeyDown}
- onBlur={handleBlur}
- className={cn(
- "text-xs font-mono bg-transparent border-none outline-none",
- "focus:bg-background focus:border focus:border-primary focus:px-1 focus:rounded",
- "tabular-nums text-primary",
- hasError && "text-destructive focus:border-destructive",
- className
- )}
- style={{ width: `${formattedTime.length + 1}ch` }}
- placeholder={formattedTime}
- />
- );
- }
-
- return (
- <span
- onClick={startEditing}
- className={cn(
- "text-xs font-mono tabular-nums text-primary cursor-pointer",
- "hover:bg-muted/50 hover:rounded px-1 -mx-1 transition-colors",
- disabled && "cursor-default hover:bg-transparent",
- className
- )}
- title={disabled ? undefined : "Click to edit time"}
- >
- {formattedTime}
- </span>
- );
-}
+"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<HTMLInputElement>(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<HTMLInputElement>) => { + 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 ( + <input + ref={inputRef} + type="text" + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className={cn( + "text-xs font-mono bg-transparent border-none outline-none", + "focus:bg-background focus:border focus:border-primary focus:px-1 focus:rounded", + "tabular-nums text-primary", + hasError && "text-destructive focus:border-destructive", + className + )} + style={{ width: `${formattedTime.length + 1}ch` }} + placeholder={formattedTime} + /> + ); + } + + return ( + <span + onClick={startEditing} + className={cn( + "text-xs font-mono tabular-nums text-primary cursor-pointer", + "hover:bg-muted/50 hover:rounded px-1 -mx-1 transition-colors", + disabled && "cursor-default hover:bg-transparent", + className + )} + title={disabled ? undefined : "Click to edit time"} + > + {formattedTime} + </span> + ); +} 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<PanelPreset, PanelSizes> = { + 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<PanelPreset, Partial<PanelSizes>>; + 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<PanelState>()( 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<PanelState> = { + 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 }; |
