mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
style: complete re-design of whole editor
This commit is contained in:
@@ -167,7 +167,7 @@ export default function Editor() {
|
|||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
className="h-full w-full gap-[0.19rem] px-2"
|
className="h-full w-full gap-[0.19rem] px-3"
|
||||||
>
|
>
|
||||||
{/* Tools Panel */}
|
{/* Tools Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@@ -175,7 +175,7 @@ export default function Editor() {
|
|||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={40}
|
maxSize={40}
|
||||||
onResize={setToolsPanel}
|
onResize={setToolsPanel}
|
||||||
className="min-w-0"
|
className="min-w-0 rounded-sm"
|
||||||
>
|
>
|
||||||
<MediaPanel />
|
<MediaPanel />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -214,7 +214,7 @@ export default function Editor() {
|
|||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={70}
|
maxSize={70}
|
||||||
onResize={setTimeline}
|
onResize={setTimeline}
|
||||||
className="min-h-0 px-2 pb-2"
|
className="min-h-0 px-3 pb-3"
|
||||||
>
|
>
|
||||||
<Timeline />
|
<Timeline />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -9,38 +9,40 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Custom colors - light mode (default) */
|
/* Custom colors - light mode (default) */
|
||||||
--background: hsl(0 0% 100%);
|
--background: hsl(0, 0%, 100%);
|
||||||
--foreground: hsl(0 0% 3.9%);
|
--foreground: hsl(0 0% 11%);
|
||||||
--card: hsl(0 0% 100%);
|
--card: hsl(0 0% 85.1%);
|
||||||
--card-foreground: hsl(0 0% 3.9%);
|
--card-foreground: hsl(0 0% 2%);
|
||||||
--popover: hsl(0 0% 100%);
|
--popover: hsl(0, 0%, 100%);
|
||||||
--popover-foreground: hsl(0 0% 3.9%);
|
--popover-foreground: hsl(0 0% 2%);
|
||||||
--primary: hsl(0 0% 9%);
|
--primary: hsl(205, 84%, 47%);
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
--primary-foreground: hsl(0 0% 91%);
|
||||||
--secondary: hsl(0 0% 96.1%);
|
--secondary: hsl(216, 13%, 92%);
|
||||||
--secondary-foreground: hsl(0 0% 9%);
|
--secondary-foreground: hsl(0 0% 2%);
|
||||||
--muted: hsl(0 0% 96.1%);
|
--muted: hsl(0 0% 85.1%);
|
||||||
--muted-foreground: hsl(0 0% 45.1%);
|
--muted-foreground: hsl(0 0% 36.1%);
|
||||||
--accent: hsl(0 0% 96.1%);
|
--accent: hsl(216, 13%, 92%);
|
||||||
--accent-foreground: hsl(0 0% 9%);
|
--accent-foreground: hsl(0 0% 2%);
|
||||||
--destructive: hsl(0 84.2% 60.2%);
|
--destructive: hsl(0 100% 40%);
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: hsl(0, 0%, 100%);
|
||||||
--border: hsl(0 0% 89.8%);
|
--border: hsl(0 0% 83%);
|
||||||
--input: hsl(0 0% 89.8%);
|
--input: hsl(0 0% 85.1%);
|
||||||
--ring: hsl(0 0% 3.9%);
|
--ring: hsl(0, 0%, 55%);
|
||||||
--chart-1: hsl(12 76% 61%);
|
--chart-1: hsl(220 70% 50%);
|
||||||
--chart-2: hsl(173 58% 39%);
|
--chart-2: hsl(160 60% 45%);
|
||||||
--chart-3: hsl(197 37% 24%);
|
--chart-3: hsl(30 80% 55%);
|
||||||
--chart-4: hsl(43 74% 66%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--chart-5: hsl(27 87% 67%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--sidebar-background: hsl(0 0% 100%);
|
--sidebar-background: hsl(0 0% 96.1%);
|
||||||
--sidebar-foreground: hsl(0 0% 3.9%);
|
--sidebar-foreground: hsl(0 0% 2%);
|
||||||
--sidebar-primary: hsl(0 0% 9%);
|
--sidebar-primary: hsl(0 0% 2%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||||
--sidebar-accent: hsl(0 0% 96.1%);
|
--sidebar-accent: hsl(0 0% 85.1%);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 9%);
|
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||||
--sidebar-border: hsl(0 0% 89.8%);
|
--sidebar-border: hsl(0 0% 85.1%);
|
||||||
--sidebar-ring: hsl(0 0% 3.9%);
|
--sidebar-ring: hsl(0 0% 16.9%);
|
||||||
|
--panel-background: hsl(216 13% 92%);
|
||||||
|
--panel-accent: hsl(216, 8%, 86%);
|
||||||
|
|
||||||
/* Radius base */
|
/* Radius base */
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
@@ -224,3 +226,20 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-thin {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function RootLayout({
|
|||||||
<BotIdClient protect={protectedRoutes} />
|
<BotIdClient protect={protectedRoutes} />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<StorageProvider>{children}</StorageProvider>
|
<StorageProvider>{children}</StorageProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ChevronDown, ArrowLeft, Download, SquarePen, Trash } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ArrowLeft,
|
||||||
|
Download,
|
||||||
|
SquarePen,
|
||||||
|
Trash,
|
||||||
|
Sun,
|
||||||
|
} from "lucide-react";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
@@ -19,14 +26,18 @@ import Link from "next/link";
|
|||||||
import { RenameProjectDialog } from "./rename-project-dialog";
|
import { RenameProjectDialog } from "./rename-project-dialog";
|
||||||
import { DeleteProjectDialog } from "./delete-project-dialog";
|
import { DeleteProjectDialog } from "./delete-project-dialog";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaDiscord, FaGithub } from "react-icons/fa6";
|
import { FaDiscord } from "react-icons/fa6";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
|
||||||
export function EditorHeader() {
|
export function EditorHeader() {
|
||||||
const { getTotalDuration } = useTimelineStore();
|
const { getTotalDuration } = useTimelineStore();
|
||||||
|
const { currentTime } = usePlaybackStore();
|
||||||
const { activeProject, renameProject, deleteProject } = useProjectStore();
|
const { activeProject, renameProject, deleteProject } = useProjectStore();
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
// TODO: Implement export functionality
|
// TODO: Implement export functionality
|
||||||
@@ -120,7 +131,11 @@ export function EditorHeader() {
|
|||||||
|
|
||||||
const centerContent = (
|
const centerContent = (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span>
|
<span className="text-foreground tabular-nums">
|
||||||
|
{formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground/50">/</span>
|
||||||
|
<span className="text-foreground/50 tabular-nums">
|
||||||
{formatTimeCode(
|
{formatTimeCode(
|
||||||
getTotalDuration(),
|
getTotalDuration(),
|
||||||
"HH:MM:SS:FF",
|
"HH:MM:SS:FF",
|
||||||
@@ -135,11 +150,20 @@ export function EditorHeader() {
|
|||||||
<KeyboardShortcutsHelp />
|
<KeyboardShortcutsHelp />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity"
|
className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
<span className="text-sm">Export</span>
|
<span className="text-sm pr-1">Export</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="text"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
<Sun className="!size-[1.1rem]" />
|
||||||
|
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@@ -149,7 +173,7 @@ export function EditorHeader() {
|
|||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
centerContent={centerContent}
|
centerContent={centerContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
className="bg-background h-[3.2rem] px-4 items-center"
|
className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MediaView } from "./views/media";
|
|||||||
import { useMediaPanelStore, Tab } from "./store";
|
import { useMediaPanelStore, Tab } from "./store";
|
||||||
import { TextView } from "./views/text";
|
import { TextView } from "./views/text";
|
||||||
import { AudioView } from "./views/audio";
|
import { AudioView } from "./views/audio";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export function MediaPanel() {
|
export function MediaPanel() {
|
||||||
const { activeTab } = useMediaPanelStore();
|
const { activeTab } = useMediaPanelStore();
|
||||||
@@ -46,9 +47,10 @@ export function MediaPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-panel rounded-sm">
|
<div className="h-full flex bg-panel">
|
||||||
<TabBar />
|
<TabBar />
|
||||||
<div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</div>
|
<Separator orientation="vertical" />
|
||||||
|
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function TabBar() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex pt-4">
|
||||||
<ScrollButton
|
<ScrollButton
|
||||||
direction="left"
|
direction="left"
|
||||||
onClick={scrollToStart}
|
onClick={scrollToStart}
|
||||||
@@ -69,7 +69,7 @@ export function TabBar() {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
||||||
>
|
>
|
||||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||||
const tab = tabs[tabKey];
|
const tab = tabs[tabKey];
|
||||||
@@ -83,7 +83,6 @@ export function TabBar() {
|
|||||||
key={tabKey}
|
key={tabKey}
|
||||||
>
|
>
|
||||||
<tab.icon className="size-[1.1rem]!" />
|
<tab.icon className="size-[1.1rem]!" />
|
||||||
<span className="text-[0.65rem]">{tab.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function AudioView() {
|
|||||||
<div className="h-full flex flex-col gap-2 p-4">
|
<div className="h-full flex flex-col gap-2 p-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search songs and artists"
|
placeholder="Search songs and artists"
|
||||||
|
className="bg-panel-accent"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,8 +3,18 @@
|
|||||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
|
import {
|
||||||
import { useEffect, useRef, useState } from "react";
|
ArrowDown01,
|
||||||
|
CloudUpload,
|
||||||
|
Grid2X2,
|
||||||
|
Image,
|
||||||
|
List,
|
||||||
|
Loader2,
|
||||||
|
Music,
|
||||||
|
Search,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||||
@@ -14,27 +24,62 @@ import {
|
|||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectItem,
|
DropdownMenuItem,
|
||||||
SelectTrigger,
|
DropdownMenuTrigger,
|
||||||
SelectValue,
|
} from "@/components/ui/dropdown-menu";
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
|
function MediaItemWithContextMenu({
|
||||||
|
item,
|
||||||
|
children,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
item: MediaItem;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={(e) => onRemove(e, item.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
|
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mediaFilter, setMediaFilter] = useState("all");
|
const [mediaFilter, setMediaFilter] = useState("all");
|
||||||
|
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||||
|
"name"
|
||||||
|
);
|
||||||
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
const processFiles = async (files: FileList | File[]) => {
|
const processFiles = async (files: FileList | File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
@@ -100,7 +145,7 @@ export function MediaView() {
|
|||||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filtered = mediaItems.filter((item) => {
|
let filtered = mediaItems.filter((item) => {
|
||||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -115,79 +160,117 @@ export function MediaView() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let valueA: any;
|
||||||
|
let valueB: any;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case "name":
|
||||||
|
valueA = a.name.toLowerCase();
|
||||||
|
valueB = b.name.toLowerCase();
|
||||||
|
break;
|
||||||
|
case "type":
|
||||||
|
valueA = a.type;
|
||||||
|
valueB = b.type;
|
||||||
|
break;
|
||||||
|
case "duration":
|
||||||
|
valueA = a.duration || 0;
|
||||||
|
valueB = b.duration || 0;
|
||||||
|
break;
|
||||||
|
case "size":
|
||||||
|
valueA = a.file.size;
|
||||||
|
valueB = b.file.size;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
||||||
|
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
setFilteredMediaItems(filtered);
|
setFilteredMediaItems(filtered);
|
||||||
}, [mediaItems, mediaFilter, searchQuery]);
|
}, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]);
|
||||||
|
|
||||||
const renderPreview = (item: MediaItem) => {
|
const previewComponents = useMemo(() => {
|
||||||
// Render a preview for each media type (image, video, audio, unknown)
|
const previews = new Map<string, React.ReactNode>();
|
||||||
if (item.type === "image") {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src={item.url}
|
|
||||||
alt={item.name}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === "video") {
|
filteredMediaItems.forEach((item) => {
|
||||||
if (item.thumbnailUrl) {
|
let preview: React.ReactNode;
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full">
|
if (item.type === "image") {
|
||||||
|
preview = (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={item.thumbnailUrl}
|
src={item.url}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-full object-cover rounded"
|
className="max-w-full max-h-full object-contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
</div>
|
||||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
);
|
||||||
</div>
|
} else if (item.type === "video") {
|
||||||
{item.duration && (
|
if (item.thumbnailUrl) {
|
||||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
preview = (
|
||||||
{formatDuration(item.duration)}
|
<div className="relative w-full h-full">
|
||||||
|
<img
|
||||||
|
src={item.thumbnailUrl}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-full object-cover rounded"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||||
|
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||||
</div>
|
</div>
|
||||||
|
{item.duration && (
|
||||||
|
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||||
|
{formatDuration(item.duration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
preview = (
|
||||||
|
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||||
|
<Video className="h-6 w-6 mb-1" />
|
||||||
|
<span className="text-xs">Video</span>
|
||||||
|
{item.duration && (
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
{formatDuration(item.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (item.type === "audio") {
|
||||||
|
preview = (
|
||||||
|
<div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||||
|
<Music className="h-6 w-6 mb-1" />
|
||||||
|
<span className="text-xs">Audio</span>
|
||||||
|
{item.duration && (
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
{formatDuration(item.duration)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
preview = (
|
||||||
|
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||||
|
<Image className="h-6 w-6" />
|
||||||
|
<span className="text-xs mt-1">Unknown</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
|
||||||
<Video className="h-6 w-6 mb-1" />
|
|
||||||
<span className="text-xs">Video</span>
|
|
||||||
{item.duration && (
|
|
||||||
<span className="text-xs opacity-70">
|
|
||||||
{formatDuration(item.duration)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === "audio") {
|
previews.set(item.id, preview);
|
||||||
return (
|
});
|
||||||
<div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
|
||||||
<Music className="h-6 w-6 mb-1" />
|
|
||||||
<span className="text-xs">Audio</span>
|
|
||||||
{item.duration && (
|
|
||||||
<span className="text-xs opacity-70">
|
|
||||||
{formatDuration(item.duration)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return previews;
|
||||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
}, [filteredMediaItems]);
|
||||||
<Image className="h-6 w-6" />
|
|
||||||
<span className="text-xs mt-1">Unknown</span>
|
const renderPreview = (item: MediaItem) => previewComponents.get(item.id);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -207,43 +290,152 @@ export function MediaView() {
|
|||||||
>
|
>
|
||||||
<div className="p-3 pb-2 bg-panel">
|
<div className="p-3 pb-2 bg-panel">
|
||||||
{/* Search and filter controls */}
|
{/* Search and filter controls */}
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
|
||||||
<SelectTrigger className="w-[80px] h-9 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All</SelectItem>
|
|
||||||
<SelectItem value="video">Video</SelectItem>
|
|
||||||
<SelectItem value="audio">Audio</SelectItem>
|
|
||||||
<SelectItem value="image">Image</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search media..."
|
|
||||||
className="min-w-[60px] flex-1 h-9 text-xs"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleFileSelect}
|
onClick={handleFileSelect}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
|
className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity"
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="h-4 w-4" />
|
<CloudUpload className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
<span>Upload</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="text"
|
||||||
|
onClick={() =>
|
||||||
|
setMediaViewMode(
|
||||||
|
mediaViewMode === "grid" ? "list" : "grid"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="justify-center items-center"
|
||||||
|
>
|
||||||
|
{mediaViewMode === "grid" ? (
|
||||||
|
<List strokeWidth={1.5} className="!size-[1.05rem]" />
|
||||||
|
) : (
|
||||||
|
<Grid2X2
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className="!size-[1.05rem]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{mediaViewMode === "grid"
|
||||||
|
? "Switch to list view"
|
||||||
|
: "Switch to grid view"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
<Tooltip>
|
||||||
|
<DropdownMenu>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="text"
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="justify-center items-center"
|
||||||
|
>
|
||||||
|
<ArrowDown01
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className="!size-[1.05rem]"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (sortBy === "name") {
|
||||||
|
setSortOrder(
|
||||||
|
sortOrder === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSortBy("name");
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name{" "}
|
||||||
|
{sortBy === "name" &&
|
||||||
|
(sortOrder === "asc" ? "↑" : "↓")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (sortBy === "type") {
|
||||||
|
setSortOrder(
|
||||||
|
sortOrder === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSortBy("type");
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Type{" "}
|
||||||
|
{sortBy === "type" &&
|
||||||
|
(sortOrder === "asc" ? "↑" : "↓")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (sortBy === "duration") {
|
||||||
|
setSortOrder(
|
||||||
|
sortOrder === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSortBy("duration");
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Duration{" "}
|
||||||
|
{sortBy === "duration" &&
|
||||||
|
(sortOrder === "asc" ? "↑" : "↓")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (sortBy === "size") {
|
||||||
|
setSortOrder(
|
||||||
|
sortOrder === "asc" ? "desc" : "asc"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSortBy("size");
|
||||||
|
setSortOrder("asc");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
File Size{" "}
|
||||||
|
{sortBy === "size" &&
|
||||||
|
(sortOrder === "asc" ? "↑" : "↓")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Sort by {sortBy} (
|
||||||
|
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="h-full">
|
<div className="h-full w-full overflow-y-auto scrollbar-thin">
|
||||||
<div className="flex-1 p-3 pt-0">
|
<div className="flex-1 p-3 pt-0 w-full">
|
||||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||||
<MediaDragOverlay
|
<MediaDragOverlay
|
||||||
isVisible={true}
|
isVisible={true}
|
||||||
@@ -252,50 +444,105 @@ export function MediaView() {
|
|||||||
onClick={handleFileSelect}
|
onClick={handleFileSelect}
|
||||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||||
/>
|
/>
|
||||||
|
) : mediaViewMode === "grid" ? (
|
||||||
|
<GridView
|
||||||
|
filteredMediaItems={filteredMediaItems}
|
||||||
|
renderPreview={renderPreview}
|
||||||
|
handleRemove={handleRemove}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<ListView
|
||||||
className="grid gap-2"
|
filteredMediaItems={filteredMediaItems}
|
||||||
style={{
|
renderPreview={renderPreview}
|
||||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
handleRemove={handleRemove}
|
||||||
}}
|
formatDuration={formatDuration}
|
||||||
>
|
/>
|
||||||
{/* Render each media item as a draggable button */}
|
|
||||||
{filteredMediaItems.map((item) => (
|
|
||||||
<ContextMenu key={item.id}>
|
|
||||||
<ContextMenuTrigger>
|
|
||||||
<DraggableMediaItem
|
|
||||||
name={item.name}
|
|
||||||
preview={renderPreview(item)}
|
|
||||||
dragData={{
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
name: item.name,
|
|
||||||
}}
|
|
||||||
showPlusOnDrag={false}
|
|
||||||
onAddToTimeline={(currentTime) =>
|
|
||||||
useTimelineStore
|
|
||||||
.getState()
|
|
||||||
.addMediaAtTime(item, currentTime)
|
|
||||||
}
|
|
||||||
rounded={false}
|
|
||||||
/>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={(e) => handleRemove(e, item.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GridView({
|
||||||
|
filteredMediaItems,
|
||||||
|
renderPreview,
|
||||||
|
handleRemove,
|
||||||
|
}: {
|
||||||
|
filteredMediaItems: MediaItem[];
|
||||||
|
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||||
|
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid gap-2"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMediaItems.map((item) => (
|
||||||
|
<MediaItemWithContextMenu
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
>
|
||||||
|
<DraggableMediaItem
|
||||||
|
name={item.name}
|
||||||
|
preview={renderPreview(item)}
|
||||||
|
dragData={{
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
}}
|
||||||
|
showPlusOnDrag={false}
|
||||||
|
onAddToTimeline={(currentTime) =>
|
||||||
|
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||||
|
}
|
||||||
|
rounded={false}
|
||||||
|
variant="card"
|
||||||
|
/>
|
||||||
|
</MediaItemWithContextMenu>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListView({
|
||||||
|
filteredMediaItems,
|
||||||
|
renderPreview,
|
||||||
|
handleRemove,
|
||||||
|
formatDuration,
|
||||||
|
}: {
|
||||||
|
filteredMediaItems: MediaItem[];
|
||||||
|
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||||
|
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||||
|
formatDuration: (duration: number) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredMediaItems.map((item) => (
|
||||||
|
<MediaItemWithContextMenu
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
>
|
||||||
|
<DraggableMediaItem
|
||||||
|
name={item.name}
|
||||||
|
preview={renderPreview(item)}
|
||||||
|
dragData={{
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
}}
|
||||||
|
showPlusOnDrag={false}
|
||||||
|
onAddToTimeline={(currentTime) =>
|
||||||
|
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||||
|
}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</MediaItemWithContextMenu>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function TextView() {
|
|||||||
<DraggableMediaItem
|
<DraggableMediaItem
|
||||||
name="Default text"
|
name="Default text"
|
||||||
preview={
|
preview={
|
||||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
|
||||||
<span className="text-xs select-none">Default text</span>
|
<span className="text-xs select-none">Default text</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,15 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
|||||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
|
||||||
import { VideoPlayer } from "@/components/ui/video-player";
|
import { VideoPlayer } from "@/components/ui/video-player";
|
||||||
import { AudioPlayer } from "@/components/ui/audio-player";
|
import { AudioPlayer } from "@/components/ui/audio-player";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
|
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||||
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||||
import { BackgroundSettings } from "../background-settings";
|
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { TextElementDragState } from "@/types/editor";
|
import { TextElementDragState } from "@/types/editor";
|
||||||
|
|
||||||
@@ -412,7 +403,7 @@ export function PreviewPanel() {
|
|||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl mb-2">🎬</div>
|
<div className="text-2xl mb-2">🎬</div>
|
||||||
<p className="text-xs text-white">{element.name}</p>
|
<p className="text-xs text-foreground">{element.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -476,10 +467,10 @@ export function PreviewPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
|
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
|
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
|
||||||
>
|
>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{hasAnyElements ? (
|
{hasAnyElements ? (
|
||||||
@@ -621,9 +612,9 @@ function FullscreenToolbar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-toolbar
|
data-toolbar
|
||||||
className="flex items-center gap-2 p-1 pt-2 w-full text-white"
|
className="flex items-center gap-2 p-1 pt-2 w-full text-foreground relative"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
|
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-foreground/90">
|
||||||
<EditableTimecode
|
<EditableTimecode
|
||||||
time={currentTime}
|
time={currentTime}
|
||||||
duration={totalDuration}
|
duration={totalDuration}
|
||||||
@@ -631,7 +622,7 @@ function FullscreenToolbar({
|
|||||||
fps={activeProject?.fps || 30}
|
fps={activeProject?.fps || 30}
|
||||||
onTimeChange={seek}
|
onTimeChange={seek}
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
className="text-white/90 hover:bg-white/10"
|
className="text-foreground/90 hover:bg-white/10"
|
||||||
/>
|
/>
|
||||||
<span className="opacity-50">/</span>
|
<span className="opacity-50">/</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -649,7 +640,7 @@ function FullscreenToolbar({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={skipBackward}
|
onClick={skipBackward}
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
className="h-auto p-0 text-foreground"
|
||||||
title="Skip backward 1s"
|
title="Skip backward 1s"
|
||||||
>
|
>
|
||||||
<SkipBack className="h-3 w-3" />
|
<SkipBack className="h-3 w-3" />
|
||||||
@@ -659,7 +650,7 @@ function FullscreenToolbar({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
className="h-auto p-0 text-foreground hover:text-foreground/80"
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="h-3 w-3" />
|
<Pause className="h-3 w-3" />
|
||||||
@@ -672,7 +663,7 @@ function FullscreenToolbar({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={skipForward}
|
onClick={skipForward}
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
className="h-auto p-0 text-foreground hover:text-foreground/80"
|
||||||
title="Skip forward 1s"
|
title="Skip forward 1s"
|
||||||
>
|
>
|
||||||
<SkipForward className="h-3 w-3" />
|
<SkipForward className="h-3 w-3" />
|
||||||
@@ -682,7 +673,7 @@ function FullscreenToolbar({
|
|||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-1 rounded-full cursor-pointer flex-1 bg-white/20",
|
"relative h-1 rounded-full cursor-pointer flex-1 bg-foreground/20",
|
||||||
!hasAnyElements && "opacity-50 cursor-not-allowed"
|
!hasAnyElements && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
onClick={hasAnyElements ? handleTimelineClick : undefined}
|
onClick={hasAnyElements ? handleTimelineClick : undefined}
|
||||||
@@ -691,13 +682,13 @@ function FullscreenToolbar({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-0 left-0 h-full rounded-full bg-white",
|
"absolute top-0 left-0 h-full rounded-full bg-foreground",
|
||||||
!isDragging && "duration-100"
|
!isDragging && "duration-100"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-white border border-black/20"
|
className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-foreground border border-black/20"
|
||||||
style={{ left: `${progress}%` }}
|
style={{ left: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,7 +697,7 @@ function FullscreenToolbar({
|
|||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-4! text-white/80 hover:text-white"
|
className="size-4! text-foreground/80 hover:text-foreground"
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
title="Exit fullscreen (Esc)"
|
title="Exit fullscreen (Esc)"
|
||||||
>
|
>
|
||||||
@@ -776,7 +767,7 @@ function FullscreenPreview({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-black">
|
<div className="p-4 bg-background">
|
||||||
<FullscreenToolbar
|
<FullscreenToolbar
|
||||||
hasAnyElements={hasAnyElements}
|
hasAnyElements={hasAnyElements}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
@@ -807,37 +798,7 @@ function PreviewToolbar({
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
}) {
|
}) {
|
||||||
const { isPlaying, seek } = usePlaybackStore();
|
const { isPlaying } = usePlaybackStore();
|
||||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
|
||||||
const { activeProject } = useProjectStore();
|
|
||||||
const {
|
|
||||||
currentPreset,
|
|
||||||
isOriginal,
|
|
||||||
getOriginalAspectRatio,
|
|
||||||
getDisplayName,
|
|
||||||
canvasPresets,
|
|
||||||
} = useAspectRatio();
|
|
||||||
|
|
||||||
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
|
||||||
setCanvasSize({ width: preset.width, height: preset.height });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOriginalSelect = () => {
|
|
||||||
const aspectRatio = getOriginalAspectRatio();
|
|
||||||
setCanvasSizeToOriginal(aspectRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalDuration = getTotalDuration();
|
|
||||||
|
|
||||||
const skipBackward = () => {
|
|
||||||
const newTime = Math.max(0, currentTime - 1);
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipForward = () => {
|
|
||||||
const newTime = Math.min(totalDuration, currentTime + 1);
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
@@ -857,50 +818,15 @@ function PreviewToolbar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-toolbar
|
data-toolbar
|
||||||
className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
|
className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]",
|
|
||||||
!hasAnyElements && "opacity-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<EditableTimecode
|
|
||||||
time={currentTime}
|
|
||||||
duration={getTotalDuration()}
|
|
||||||
format="HH:MM:SS:FF"
|
|
||||||
fps={activeProject?.fps || 30}
|
|
||||||
onTimeChange={seek}
|
|
||||||
disabled={!hasAnyElements}
|
|
||||||
/>
|
|
||||||
<span className="opacity-50">/</span>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{formatTimeCode(
|
|
||||||
getTotalDuration(),
|
|
||||||
"HH:MM:SS:FF",
|
|
||||||
activeProject?.fps || 30
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="icon"
|
|
||||||
onClick={skipBackward}
|
|
||||||
disabled={!hasAnyElements}
|
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
|
||||||
title="Skip backward 1s"
|
|
||||||
>
|
|
||||||
<SkipBack className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
className="h-auto p-0"
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="h-3 w-3" />
|
<Pause className="h-3 w-3" />
|
||||||
@@ -911,52 +837,7 @@ function PreviewToolbar({
|
|||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={skipForward}
|
className="size-4!"
|
||||||
disabled={!hasAnyElements}
|
|
||||||
className="h-auto p-0 text-white hover:text-white/80"
|
|
||||||
title="Skip forward 1s"
|
|
||||||
>
|
|
||||||
<SkipForward className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BackgroundSettings />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-panel-accent! text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
|
||||||
disabled={!hasAnyElements}
|
|
||||||
>
|
|
||||||
{getDisplayName()}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleOriginalSelect}
|
|
||||||
className={cn("text-xs", isOriginal && "font-semibold")}
|
|
||||||
>
|
|
||||||
Original
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{canvasPresets.map((preset) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={preset.name}
|
|
||||||
onClick={() => handlePresetSelect(preset)}
|
|
||||||
className={cn(
|
|
||||||
"text-xs",
|
|
||||||
currentPreset?.name === preset.name && "font-semibold"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preset.name}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="icon"
|
|
||||||
className="size-4! text-muted-foreground"
|
|
||||||
onClick={onToggleExpanded}
|
onClick={onToggleExpanded}
|
||||||
title="Enter fullscreen"
|
title="Enter fullscreen"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
Eye,
|
||||||
|
MicOff,
|
||||||
|
Mic,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -544,7 +547,7 @@ export function Timeline() {
|
|||||||
{/* Timeline Header with Ruler */}
|
{/* Timeline Header with Ruler */}
|
||||||
<div className="flex bg-panel sticky top-0 z-10">
|
<div className="flex bg-panel sticky top-0 z-10">
|
||||||
{/* Track Labels Header */}
|
{/* Track Labels Header */}
|
||||||
<div className="w-48 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
|
<div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
|
||||||
{/* Empty space */}
|
{/* Empty space */}
|
||||||
<span className="text-sm font-medium text-muted-foreground opacity-0">
|
<span className="text-sm font-medium text-muted-foreground opacity-0">
|
||||||
.
|
.
|
||||||
@@ -659,7 +662,7 @@ export function Timeline() {
|
|||||||
if (!activeProject?.bookmarks?.length) return null;
|
if (!activeProject?.bookmarks?.length) return null;
|
||||||
|
|
||||||
return activeProject.bookmarks.map((bookmarkTime, i) => (
|
return activeProject.bookmarks.map((bookmarkTime, i) => (
|
||||||
<div
|
<div
|
||||||
key={`bookmark-${i}`}
|
key={`bookmark-${i}`}
|
||||||
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
|
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
@@ -687,7 +690,7 @@ export function Timeline() {
|
|||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={trackLabelsRef}
|
ref={trackLabelsRef}
|
||||||
className="w-48 shrink-0 border-r border-black overflow-y-auto z-200 bg-panel"
|
className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel"
|
||||||
data-track-labels
|
data-track-labels
|
||||||
>
|
>
|
||||||
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
|
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
|
||||||
@@ -695,17 +698,24 @@ export function Timeline() {
|
|||||||
{tracks.map((track) => (
|
{tracks.map((track) => (
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id}
|
||||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
className="flex items-center px-3 group"
|
||||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<div className="flex items-center justify-end flex-1 min-w-0 gap-2">
|
||||||
|
{track.muted ? (
|
||||||
|
<MicOff
|
||||||
|
className="h-4 w-4 text-destructive cursor-pointer"
|
||||||
|
onClick={() => toggleTrackMute(track.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Mic
|
||||||
|
className="h-4 w-4 text-muted-foreground cursor-pointer"
|
||||||
|
onClick={() => toggleTrackMute(track.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
<TrackIcon track={track} />
|
<TrackIcon track={track} />
|
||||||
</div>
|
</div>
|
||||||
{track.muted && (
|
|
||||||
<span className="ml-2 text-xs text-red-500 font-semibold shrink-0">
|
|
||||||
Muted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -760,7 +770,7 @@ export function Timeline() {
|
|||||||
<ContextMenu key={track.id}>
|
<ContextMenu key={track.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
|
className="absolute left-0 right-0"
|
||||||
style={{
|
style={{
|
||||||
top: `${getCumulativeHeightBefore(
|
top: `${getCumulativeHeightBefore(
|
||||||
tracks,
|
tracks,
|
||||||
@@ -798,23 +808,6 @@ export function Timeline() {
|
|||||||
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
|
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
|
||||||
Track settings (soon)
|
Track settings (soon)
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem disabled>Bookmarks</ContextMenuItem>
|
|
||||||
{activeProject.bookmarks.map((bookmarkTime, i) => (
|
|
||||||
<ContextMenuItem
|
|
||||||
key={`bookmark-menu-${i}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
seek(bookmarkTime);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Bookmark className="h-3 w-3 mr-2 inline-block" />
|
|
||||||
{bookmarkTime.toFixed(1)}s
|
|
||||||
</ContextMenuItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
@@ -1142,7 +1135,9 @@ function TimelineToolbar({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
||||||
<Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} />
|
<Bookmark
|
||||||
|
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../../ui/button";
|
|
||||||
import {
|
import {
|
||||||
MoreVertical,
|
|
||||||
Scissors,
|
Scissors,
|
||||||
Trash2,
|
Trash2,
|
||||||
SplitSquareHorizontal,
|
|
||||||
Music,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronLeft,
|
|
||||||
Type,
|
|
||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -23,23 +15,13 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import AudioWaveform from "../audio-waveform";
|
import AudioWaveform from "../audio-waveform";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
import { TimelineElementProps } from "@/types/timeline";
|
||||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||||
import {
|
import {
|
||||||
getTrackElementClasses,
|
getTrackElementClasses,
|
||||||
TIMELINE_CONSTANTS,
|
TIMELINE_CONSTANTS,
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
} from "@/constants/timeline-constants";
|
} from "@/constants/timeline-constants";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
} from "../../ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -64,9 +46,6 @@ export function TimelineElement({
|
|||||||
removeElementFromTrackWithRipple,
|
removeElementFromTrackWithRipple,
|
||||||
dragState,
|
dragState,
|
||||||
splitElement,
|
splitElement,
|
||||||
splitAndKeepLeft,
|
|
||||||
splitAndKeepRight,
|
|
||||||
separateAudio,
|
|
||||||
addElementToTrack,
|
addElementToTrack,
|
||||||
replaceElementMedia,
|
replaceElementMedia,
|
||||||
rippleEditingEnabled,
|
rippleEditingEnabled,
|
||||||
@@ -74,27 +53,20 @@ export function TimelineElement({
|
|||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const mediaItem =
|
const mediaItem =
|
||||||
element.type === "media"
|
element.type === "media"
|
||||||
? mediaItems.find((item) => item.id === element.mediaId)
|
? mediaItems.find((item) => item.id === element.mediaId)
|
||||||
: null;
|
: null;
|
||||||
const isAudio = mediaItem?.type === "audio";
|
const isAudio = mediaItem?.type === "audio";
|
||||||
|
|
||||||
const {
|
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||||
resizing,
|
useTimelineElementResize({
|
||||||
isResizing,
|
element,
|
||||||
handleResizeStart,
|
track,
|
||||||
handleResizeMove,
|
zoomLevel,
|
||||||
handleResizeEnd,
|
onUpdateTrim: updateElementTrim,
|
||||||
} = useTimelineElementResize({
|
onUpdateDuration: updateElementDuration,
|
||||||
element,
|
});
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
onUpdateTrim: updateElementTrim,
|
|
||||||
onUpdateDuration: updateElementDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
const effectiveDuration =
|
const effectiveDuration =
|
||||||
element.duration - element.trimStart - element.trimEnd;
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
@@ -193,9 +165,7 @@ export function TimelineElement({
|
|||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
<div className="w-full h-full flex items-center justify-start pl-2">
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
<span className="text-xs text-white truncate">{element.content}</span>
|
||||||
{element.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,86 +182,36 @@ export function TimelineElement({
|
|||||||
|
|
||||||
const TILE_ASPECT_RATIO = 16 / 9;
|
const TILE_ASPECT_RATIO = 16 / 9;
|
||||||
|
|
||||||
if (mediaItem.type === "image") {
|
if (
|
||||||
|
mediaItem.type === "image" ||
|
||||||
|
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
|
||||||
|
) {
|
||||||
// Calculate tile size based on 16:9 aspect ratio
|
// Calculate tile size based on 16:9 aspect ratio
|
||||||
const trackHeight = getTrackHeight(track.type);
|
const trackHeight = getTrackHeight(track.type);
|
||||||
const tileHeight = trackHeight - 8; // Account for padding
|
const tileHeight = trackHeight;
|
||||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||||
|
|
||||||
|
const imageUrl =
|
||||||
|
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
|
||||||
|
const isImage = mediaItem.type === "image";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
<div
|
||||||
{/* Background with tiled images */}
|
className={`w-full h-full relative ${
|
||||||
|
isSelected ? "bg-primary" : "bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute top-3 bottom-3 left-0 right-0"
|
className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: mediaItem.url
|
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||||
? `url(${mediaItem.url})`
|
|
||||||
: "none",
|
|
||||||
backgroundRepeat: "repeat-x",
|
backgroundRepeat: "repeat-x",
|
||||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||||
backgroundPosition: "left center",
|
backgroundPosition: "left center",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
aria-label={`Tiled background of ${mediaItem.name}`}
|
aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||||
/>
|
|
||||||
{/* Overlay with vertical borders */}
|
|
||||||
<div
|
|
||||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `repeating-linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent 0px,
|
|
||||||
transparent ${tileWidth - 1}px,
|
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
|
||||||
)`,
|
|
||||||
backgroundPosition: "left center",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIDEO_TILE_PADDING = 16;
|
|
||||||
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
|
||||||
|
|
||||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
|
||||||
const trackHeight = getTrackHeight(track.type);
|
|
||||||
const tileHeight = trackHeight - 8; // Match image padding
|
|
||||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
|
||||||
{/* Background with tiled thumbnails */}
|
|
||||||
<div
|
|
||||||
className="absolute top-3 bottom-3 left-0 right-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage: mediaItem.thumbnailUrl
|
|
||||||
? `url(${mediaItem.thumbnailUrl})`
|
|
||||||
: "none",
|
|
||||||
backgroundRepeat: "repeat-x",
|
|
||||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
|
||||||
backgroundPosition: "left center",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
|
||||||
/>
|
|
||||||
{/* Overlay with vertical borders */}
|
|
||||||
<div
|
|
||||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `repeating-linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent 0px,
|
|
||||||
transparent ${tileWidth - 1}px,
|
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
|
||||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
|
||||||
)`,
|
|
||||||
backgroundPosition: "left center",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +266,7 @@ export function TimelineElement({
|
|||||||
<div
|
<div
|
||||||
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
||||||
track.type
|
track.type
|
||||||
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
)} ${isSelected ? "" : ""} ${
|
||||||
isBeingDragged ? "z-50" : "z-10"
|
isBeingDragged ? "z-50" : "z-10"
|
||||||
} ${element.hidden ? "opacity-50" : ""}`}
|
} ${element.hidden ? "opacity-50" : ""}`}
|
||||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||||
@@ -372,11 +292,11 @@ export function TimelineElement({
|
|||||||
{isSelected && (
|
{isSelected && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary z-50"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -407,8 +327,8 @@ export function TimelineElement({
|
|||||||
? "Unmute"
|
? "Unmute"
|
||||||
: "Mute"
|
: "Mute"
|
||||||
: element.hidden
|
: element.hidden
|
||||||
? "Show"
|
? "Show"
|
||||||
: "Hide"}{" "}
|
: "Hide"}{" "}
|
||||||
{element.type === "text" ? "text" : "clip"}
|
{element.type === "text" ? "text" : "clip"}
|
||||||
</span>
|
</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function TimelinePlayhead({
|
|||||||
|
|
||||||
// Use timeline container height minus a few pixels for breathing room
|
// Use timeline container height minus a few pixels for breathing room
|
||||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
const totalHeight = timelineContainerHeight - 4;
|
||||||
|
|
||||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||||
const trackLabelsWidth =
|
const trackLabelsWidth =
|
||||||
@@ -137,12 +137,12 @@ export function TimelinePlayhead({
|
|||||||
>
|
>
|
||||||
{/* The playhead line spanning full height */}
|
{/* The playhead line spanning full height */}
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const EditableShortcutKey = ({
|
|||||||
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 ${
|
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
|
isRecording
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-white/10 bg-black/20"
|
: "border bg-accent"
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const buttonVariants = cva(
|
|||||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better)
|
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface DraggableMediaItemProps {
|
|||||||
showPlusOnDrag?: boolean;
|
showPlusOnDrag?: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
|
variant?: "card" | "compact";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraggableMediaItem({
|
export function DraggableMediaItem({
|
||||||
@@ -37,6 +38,7 @@ export function DraggableMediaItem({
|
|||||||
showPlusOnDrag = true,
|
showPlusOnDrag = true,
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
rounded = true,
|
rounded = true,
|
||||||
|
variant = "card",
|
||||||
}: DraggableMediaItemProps) {
|
}: DraggableMediaItemProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
@@ -88,42 +90,62 @@ export function DraggableMediaItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={dragRef} className="relative group w-28 h-28">
|
{variant === "card" ? (
|
||||||
<div
|
<div ref={dragRef} className="relative group w-28 h-28">
|
||||||
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
<div
|
||||||
>
|
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
||||||
<AspectRatio
|
>
|
||||||
ratio={aspectRatio}
|
<AspectRatio
|
||||||
|
ratio={aspectRatio}
|
||||||
|
className={cn(
|
||||||
|
"bg-panel-accent relative overflow-hidden",
|
||||||
|
rounded && "rounded-md",
|
||||||
|
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||||
|
)}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
{!isDragging && (
|
||||||
|
<PlusButton
|
||||||
|
className="opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={handleAddToTimeline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AspectRatio>
|
||||||
|
{showLabel && (
|
||||||
|
<span
|
||||||
|
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
||||||
|
aria-label={name}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
{name.length > 8
|
||||||
|
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||||
|
: name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div ref={dragRef} className="relative group w-full">
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-accent relative overflow-hidden",
|
"h-10 flex items-center gap-3 cursor-default w-full",
|
||||||
rounded && "rounded-md",
|
"[&::-webkit-drag-ghost]:opacity-0",
|
||||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
className
|
||||||
)}
|
)}
|
||||||
draggable={true}
|
draggable={true}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{preview}
|
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
|
||||||
{!isDragging && (
|
{preview}
|
||||||
<PlusButton
|
</div>
|
||||||
className="opacity-0 group-hover:opacity-100"
|
<span className="text-sm truncate flex-1 w-full">{name}</span>
|
||||||
onClick={handleAddToTimeline}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AspectRatio>
|
|
||||||
{showLabel && (
|
|
||||||
<span
|
|
||||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
|
||||||
aria-label={name}
|
|
||||||
title={name}
|
|
||||||
>
|
|
||||||
{name.length > 8
|
|
||||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
|
||||||
: name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Custom drag preview */}
|
{/* Custom drag preview */}
|
||||||
{isDragging &&
|
{isDragging &&
|
||||||
@@ -171,7 +193,10 @@ function PlusButton({
|
|||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
className={cn(
|
||||||
|
"absolute bottom-2 right-2 size-4 bg-background text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
showPasswordToggle && "pr-10",
|
showPasswordToggle && "pr-10",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ export const TRACK_COLORS: Record<
|
|||||||
> = {
|
> = {
|
||||||
media: {
|
media: {
|
||||||
solid: "bg-blue-500",
|
solid: "bg-blue-500",
|
||||||
background: "bg-blue-500/20",
|
background: "",
|
||||||
border: "border-white/80",
|
border: "",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
solid: "bg-[#9C4937]",
|
solid: "bg-[#5DBAA0]",
|
||||||
background: "bg-[#9C4937]",
|
background: "bg-[#5DBAA0]",
|
||||||
border: "border-white/80",
|
border: "",
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
solid: "bg-green-500",
|
solid: "bg-green-500",
|
||||||
background: "bg-green-500/20",
|
background: "bg-[#915DBE]",
|
||||||
border: "border-white/80",
|
border: "",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export function getTrackElementClasses(type: TrackType) {
|
|||||||
|
|
||||||
// Track height definitions
|
// Track height definitions
|
||||||
export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
||||||
media: 65,
|
media: 60,
|
||||||
text: 25,
|
text: 25,
|
||||||
audio: 50,
|
audio: 50,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ interface PanelState {
|
|||||||
mainContent: number;
|
mainContent: number;
|
||||||
timeline: number;
|
timeline: number;
|
||||||
|
|
||||||
|
mediaViewMode: "grid" | "list";
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setToolsPanel: (size: number) => void;
|
setToolsPanel: (size: number) => void;
|
||||||
setPreviewPanel: (size: number) => void;
|
setPreviewPanel: (size: number) => void;
|
||||||
setPropertiesPanel: (size: number) => void;
|
setPropertiesPanel: (size: number) => void;
|
||||||
setMainContent: (size: number) => void;
|
setMainContent: (size: number) => void;
|
||||||
setTimeline: (size: number) => void;
|
setTimeline: (size: number) => void;
|
||||||
|
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePanelStore = create<PanelState>()(
|
export const usePanelStore = create<PanelState>()(
|
||||||
@@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
// Default sizes - optimized for responsiveness
|
// Default sizes - optimized for responsiveness
|
||||||
...DEFAULT_PANEL_SIZES,
|
...DEFAULT_PANEL_SIZES,
|
||||||
|
|
||||||
|
mediaViewMode: "grid" as const,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||||
setMainContent: (size) => set({ mainContent: size }),
|
setMainContent: (size) => set({ mainContent: size }),
|
||||||
setTimeline: (size) => set({ timeline: size }),
|
setTimeline: (size) => set({ timeline: size }),
|
||||||
|
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-sizes",
|
name: "panel-sizes",
|
||||||
|
|||||||
Reference in New Issue
Block a user