summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/contributors/page.tsx3
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx (renamed from apps/web/src/app/editor/page.tsx)16
-rw-r--r--apps/web/src/app/editor/editor.css4
-rw-r--r--apps/web/src/app/globals.css4
-rw-r--r--apps/web/src/app/layout.tsx3
-rw-r--r--apps/web/src/app/page.tsx7
-rw-r--r--apps/web/src/app/projects/page.tsx260
-rw-r--r--apps/web/src/components/delete-project-dialog.tsx53
-rw-r--r--apps/web/src/components/editor-header.tsx17
-rw-r--r--apps/web/src/components/editor/media-panel.tsx149
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx216
-rw-r--r--apps/web/src/components/editor/project-name-editor.tsx110
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx33
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx672
-rw-r--r--apps/web/src/components/editor/timeline.tsx3123
-rw-r--r--apps/web/src/components/footer.tsx4
-rw-r--r--apps/web/src/components/header.tsx4
-rw-r--r--apps/web/src/components/landing/hero.tsx9
-rw-r--r--apps/web/src/components/rename-project-dialog.tsx73
-rw-r--r--apps/web/src/components/storage-provider.tsx81
-rw-r--r--apps/web/src/components/ui/card.tsx2
-rw-r--r--apps/web/src/components/ui/context-menu.tsx65
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx59
-rw-r--r--apps/web/src/components/ui/select.tsx29
-rw-r--r--apps/web/src/lib/media-processing.ts166
-rw-r--r--apps/web/src/lib/storage/indexeddb-adapter.ts89
-rw-r--r--apps/web/src/lib/storage/opfs-adapter.ts73
-rw-r--r--apps/web/src/lib/storage/storage-service.ts194
-rw-r--r--apps/web/src/lib/storage/types.ts42
-rw-r--r--apps/web/src/lib/time.ts21
-rw-r--r--apps/web/src/middleware.ts10
-rw-r--r--apps/web/src/stores/editor-store.ts64
-rw-r--r--apps/web/src/stores/media-store.ts403
-rw-r--r--apps/web/src/stores/panel-store.ts14
-rw-r--r--apps/web/src/stores/project-store.ts187
-rw-r--r--apps/web/src/stores/timeline-store.ts30
-rw-r--r--apps/web/src/types/editor.ts11
-rw-r--r--apps/web/src/types/project.ts1
-rw-r--r--apps/web/src/types/timeline.ts49
39 files changed, 3735 insertions, 2615 deletions
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index 67957c32..feee2513 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -140,9 +140,6 @@ export default async function ContributorsPage() {
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
- <div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
- {index + 1}
- </div>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{contributor.login}
diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index e0013937..8eee5e0a 100644
--- a/apps/web/src/app/editor/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,16 +1,15 @@
"use client";
import { useEffect } from "react";
-import "./editor.css";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
-} from "../../components/ui/resizable";
-import { MediaPanel } from "../../components/editor/media-panel";
+} from "../../../components/ui/resizable";
+import { MediaPanel } from "../../../components/editor/media-panel";
// import { PropertiesPanel } from "../../components/editor/properties-panel";
-import { Timeline } from "../../components/editor/timeline";
-import { PreviewPanel } from "../../components/editor/preview-panel";
+import { Timeline } from "../../../components/editor/timeline";
+import { PreviewPanel } from "../../../components/editor/preview-panel";
import { EditorHeader } from "@/components/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
@@ -21,12 +20,10 @@ export default function Editor() {
const {
toolsPanel,
previewPanel,
- propertiesPanel,
mainContent,
timeline,
setToolsPanel,
setPreviewPanel,
- setPropertiesPanel,
setMainContent,
setTimeline,
} = usePanelStore();
@@ -55,7 +52,10 @@ export default function Editor() {
className="min-h-0"
>
{/* Main content area */}
- <ResizablePanelGroup direction="horizontal" className="h-full w-full">
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="h-full w-full"
+ >
{/* Tools Panel */}
<ResizablePanel
defaultSize={toolsPanel}
diff --git a/apps/web/src/app/editor/editor.css b/apps/web/src/app/editor/editor.css
deleted file mode 100644
index f482c6de..00000000
--- a/apps/web/src/app/editor/editor.css
+++ /dev/null
@@ -1,4 +0,0 @@
-/* Prevent scroll jumping on Mac devices when using the editor */
-body {
- overflow: hidden;
-} \ No newline at end of file
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index e8727432..eafdc861 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -40,10 +40,10 @@
}
.dark {
--background: 0 0% 8%;
- --foreground: 0 0% 98%;
+ --foreground: 0 0% 89%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
- --popover: 0 0% 3.9%;
+ --popover: 0 0% 14.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 8188d31b..4f8243b5 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -6,6 +6,7 @@ import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { DevelopmentDebug } from "../components/development-debug";
+import { StorageProvider } from "../components/storage-provider";
import { baseMetaData } from "./metadata";
const inter = Inter({
@@ -25,7 +26,7 @@ export default function RootLayout({
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
<TooltipProvider>
- {children}
+ <StorageProvider>{children}</StorageProvider>
<Analytics />
<Toaster />
<DevelopmentDebug />
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 183b6a56..f78e9d52 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -12,13 +12,6 @@ export default async function Home() {
return (
<div>
- <Image
- className="fixed top-0 left-0 -z-50 size-full object-cover"
- src="/landing-page-bg.png"
- height={1903.5}
- width={1269}
- alt="landing-page.bg"
- />
<Header />
<Hero signupCount={signupCount} />
<Footer />
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
new file mode 100644
index 00000000..c983894d
--- /dev/null
+++ b/apps/web/src/app/projects/page.tsx
@@ -0,0 +1,260 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ ChevronLeft,
+ Plus,
+ Calendar,
+ MoreHorizontal,
+ Video,
+ Loader2,
+} from "lucide-react";
+import { TProject } from "@/types/project";
+import Image from "next/image";
+import {
+ DropdownMenu,
+ DropdownMenuItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { useProjectStore } from "@/stores/project-store";
+import { useRouter } from "next/navigation";
+import { DeleteProjectDialog } from "@/components/delete-project-dialog";
+import { RenameProjectDialog } from "@/components/rename-project-dialog";
+
+export default function ProjectsPage() {
+ const { createNewProject, savedProjects, isLoading, isInitialized } =
+ useProjectStore();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await createNewProject("New Project");
+ console.log("projectId", projectId);
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+ <div className="min-h-screen bg-background">
+ <div className="pt-6 px-6 flex items-center justify-between w-full h-16">
+ <Link
+ href="/"
+ className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
+ >
+ <ChevronLeft className="!size-5 shrink-0" />
+ <span className="text-sm font-medium">Back</span>
+ </Link>
+ <div className="block md:hidden">
+ <CreateButton onClick={handleCreateProject} />
+ </div>
+ </div>
+ <main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
+ <div className="mb-8 flex items-center justify-between">
+ <div className="flex flex-col gap-3">
+ <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
+ Your Projects
+ </h1>
+ <p className="text-muted-foreground">
+ {savedProjects.length}{" "}
+ {savedProjects.length === 1 ? "project" : "projects"}
+ </p>
+ </div>
+ <div className="hidden md:block">
+ <CreateButton onClick={handleCreateProject} />
+ </div>
+ </div>
+
+ {isLoading || !isInitialized ? (
+ <div className="flex items-center justify-center py-16">
+ <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
+ </div>
+ ) : savedProjects.length === 0 ? (
+ <NoProjects onCreateProject={handleCreateProject} />
+ ) : (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {savedProjects.map((project) => (
+ <ProjectCard key={project.id} project={project} />
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
+
+function ProjectCard({ project }: { project: TProject }) {
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+
+ const formatDate = (date: Date): string => {
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const handleDeleteProject = async () => {
+ await deleteProject(project.id);
+ setIsDropdownOpen(false);
+ };
+
+ const handleRenameProject = async (newName: string) => {
+ await renameProject(project.id, newName);
+ setIsRenameDialogOpen(false);
+ };
+
+ const handleDuplicateProject = async () => {
+ setIsDropdownOpen(false);
+ await duplicateProject(project.id);
+ };
+
+ return (
+ <>
+ <Link href={`/editor/${project.id}`} className="block group">
+ <Card className="overflow-hidden bg-background border-none p-0">
+ <div
+ className={`relative aspect-square bg-muted transition-opacity ${
+ isDropdownOpen
+ ? "opacity-65"
+ : "opacity-100 group-hover:opacity-65"
+ }`}
+ >
+ {/* Thumbnail preview or placeholder */}
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="w-full h-full bg-muted/50 flex items-center justify-center">
+ <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
+ </div>
+ )}
+ </div>
+ </div>
+
+ <CardContent className="px-0 pt-5 flex flex-col gap-1">
+ <div className="flex items-start justify-between">
+ <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
+ {project.name}
+ </h3>
+ <DropdownMenu
+ open={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ >
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className={`size-6 p-0 transition-all shrink-0 ml-2 ${
+ isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ onClick={(e) => e.preventDefault()}
+ >
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicateProject();
+ }}
+ >
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsDeleteDialogOpen(true);
+ }}
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="space-y-1">
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
+ <Calendar className="!size-4" />
+ <span>Created {formatDate(project.createdAt)}</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ onConfirm={handleDeleteProject}
+ />
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleRenameProject}
+ projectName={project.name}
+ />
+ </>
+ );
+}
+
+function CreateButton({ onClick }: { onClick?: () => void }) {
+ return (
+ <Button className="flex" onClick={onClick}>
+ <Plus className="!size-4" />
+ <span className="text-sm font-medium">New project</span>
+ </Button>
+ );
+}
+
+function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
+ return (
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Video className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">No projects yet</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Start creating your first video project. Import media, edit, and export
+ professional videos.
+ </p>
+ <Button size="lg" className="gap-2" onClick={onCreateProject}>
+ <Plus className="h-4 w-4" />
+ Create Your First Project
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx
new file mode 100644
index 00000000..db709647
--- /dev/null
+++ b/apps/web/src/components/delete-project-dialog.tsx
@@ -0,0 +1,53 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function DeleteProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+}) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DialogHeader>
+ <DialogTitle>Delete Project</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete this project? This action cannot be
+ undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button variant="destructive" onClick={onConfirm}>
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index 5131230f..e3d2359e 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -5,38 +5,33 @@ import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
-import { ProjectNameEditor } from "./editor/project-name-editor";
+import { formatTimeCode } from "@/lib/time";
+import { useProjectStore } from "@/stores/project-store";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
+ const { activeProject } = useProjectStore();
const handleExport = () => {
// TODO: Implement export functionality
console.log("Export project");
};
- // Format duration from seconds to MM:SS format
- const formatDuration = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = Math.floor(seconds % 60);
- return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
- };
-
const leftContent = (
<div className="flex items-center gap-2">
<Link
- href="/"
+ href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
+ <span className="text-sm">{activeProject?.name}</span>
</Link>
- <ProjectNameEditor />
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
- <span>{formatDuration(getTotalDuration())}</span>
+ <span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
</div>
);
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index f717234d..3d83426e 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -4,12 +4,26 @@ import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
-import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
+import { Image, Music, Plus, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { AspectRatio } from "../ui/aspect-ratio";
import { Button } from "../ui/button";
import { DragOverlay } from "../ui/drag-overlay";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { Input } from "../ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
@@ -32,7 +46,9 @@ export function MediaPanel() {
setProgress(p)
);
// Add each processed media item to the store
- processedItems.forEach((item) => addMediaItem(item));
+ for (const item of processedItems) {
+ await addMediaItem(item);
+ }
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
@@ -56,12 +72,11 @@ export function MediaPanel() {
e.target.value = ""; // Reset input
};
- const handleRemove = (e: React.MouseEvent, id: string) => {
+ const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
-
- // Remove tracks automatically when delete media
+ // Remove tracks automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
@@ -69,12 +84,14 @@ export function MediaPanel() {
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
});
// Only remove track if it becomes empty and has no other clips
- const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
+ const updatedTrack = useTimelineStore
+ .getState()
+ .tracks.find((t) => t.id === track.id);
if (updatedTrack && updatedTrack.clips.length === 0) {
removeTrack(track.id);
}
});
- removeMediaItem(id);
+ await removeMediaItem(id);
};
const formatDuration = (duration: number) => {
@@ -128,23 +145,22 @@ export function MediaPanel() {
if (item.type === "image") {
return (
- <img
- src={item.url}
- alt={item.name}
- className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
- loading="lazy"
- {...baseDragProps}
- />
+ <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 rounded"
+ loading="lazy"
+ {...baseDragProps}
+ />
+ </div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
- <div
- className="relative w-full h-full cursor-grab active:cursor-grabbing"
- {...baseDragProps}
- >
+ <div className="relative w-full h-full" {...baseDragProps}>
<img
src={item.thumbnailUrl}
alt={item.name}
@@ -164,7 +180,7 @@ export function MediaPanel() {
}
return (
<div
- className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
+ className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"
{...baseDragProps}
>
<Video className="h-6 w-6 mb-1" />
@@ -181,7 +197,7 @@ export function MediaPanel() {
if (item.type === "audio") {
return (
<div
- className="w-full h-full bg-gradient-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 cursor-grab active:cursor-grabbing"
+ className="w-full h-full bg-gradient-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"
{...baseDragProps}
>
<Music className="h-6 w-6 mb-1" />
@@ -197,7 +213,7 @@ export function MediaPanel() {
return (
<div
- className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
+ className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"
{...baseDragProps}
>
<Image className="h-6 w-6" />
@@ -229,20 +245,21 @@ export function MediaPanel() {
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
- <select
- value={mediaFilter}
- onChange={(e) => setMediaFilter(e.target.value)}
- className="px-2 py-1 text-xs border rounded bg-background"
- >
- <option value="all">All</option>
- <option value="video">Video</option>
- <option value="audio">Audio</option>
- <option value="image">Image</option>
- </select>
- <input
+ <Select value={mediaFilter} onValueChange={setMediaFilter}>
+ <SelectTrigger className="w-[80px] h-7 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 px-2 py-1 text-xs border rounded bg-background"
+ className="min-w-[60px] flex-1 h-7 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -287,39 +304,45 @@ export function MediaPanel() {
</p>
</div>
) : (
- <div className="grid grid-cols-2 gap-2">
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 160px)",
+ }}
+ >
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<div key={item.id} className="relative group">
- <Button
- variant="outline"
- className="flex flex-col gap-2 p-2 h-auto w-full relative"
- >
- <AspectRatio ratio={item.aspectRatio}>
- {renderPreview(item)}
- </AspectRatio>
- <span
- className="text-xs truncate px-1 max-w-full"
- aria-label={item.name}
- title={item.name}
- >
- {item.name.length > 8
- ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
- : item.name}
- </span>
- </Button>
-
- {/* Show remove button on hover */}
- <div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
- <Button
- variant="destructive"
- size="icon"
- className="h-6 w-6"
- onClick={(e) => handleRemove(e, item.id)}
- >
- <Trash2 className="h-3 w-3" />
- </Button>
- </div>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="flex flex-col gap-1 p-2 h-auto w-full relative border-none !bg-transparent cursor-default"
+ >
+ <AspectRatio ratio={16 / 9} className="bg-accent">
+ {renderPreview(item)}
+ </AspectRatio>
+ <span
+ className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
+ aria-label={item.name}
+ title={item.name}
+ >
+ {item.name.length > 8
+ ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
+ : item.name}
+ </span>
+ </Button>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(e) => handleRemove(e, item.id)}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
</div>
))}
</div>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 0eee6002..65901365 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -5,12 +5,26 @@ import {
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import {
+ useMediaStore,
+ type MediaItem,
+ getMediaAspectRatio,
+} from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
+import { useEditorStore } from "@/stores/editor-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
-import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react";
import { useState, useRef, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { formatTimeCode } from "@/lib/time";
interface ActiveClip {
clip: TimelineClip;
@@ -22,7 +36,7 @@ export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
- const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
+ const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
@@ -116,6 +130,9 @@ export function PreviewPanel() {
const activeClips = getActiveClips();
+ // Check if there are any clips in the timeline at all
+ const hasAnyClips = tracks.some((track) => track.clips.length > 0);
+
// Render a clip
const renderClip = (clipData: ActiveClip, index: number) => {
const { clip, mediaItem } = clipData;
@@ -183,101 +200,150 @@ export function PreviewPanel() {
return null;
};
- // Canvas presets
- const canvasPresets = [
- { name: "16:9 HD", width: 1920, height: 1080 },
- { name: "16:9 4K", width: 3840, height: 2160 },
- { name: "9:16 Mobile", width: 1080, height: 1920 },
- { name: "1:1 Square", width: 1080, height: 1080 },
- { name: "4:3 Standard", width: 1440, height: 1080 },
- ];
-
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
- {/* Controls */}
- <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
- <span className="text-muted-foreground">Canvas:</span>
- <select
- value={`${canvasSize.width}x${canvasSize.height}`}
- onChange={(e) => {
- const preset = canvasPresets.find(
- (p) => `${p.width}x${p.height}` === e.target.value
- );
- if (preset)
- setCanvasSize({ width: preset.width, height: preset.height });
- }}
- className="bg-background border rounded px-2 py-1 text-xs"
- >
- {canvasPresets.map((preset) => (
- <option
- key={preset.name}
- value={`${preset.width}x${preset.height}`}
- >
- {preset.name} ({preset.width}×{preset.height})
- </option>
- ))}
- </select>
-
- <Button
- variant="outline"
- size="sm"
- onClick={toggleMute}
- className="ml-auto"
- >
- {muted || volume === 0 ? (
- <VolumeX className="h-3 w-3 mr-1" />
- ) : (
- <Volume2 className="h-3 w-3 mr-1" />
- )}
- {muted || volume === 0 ? "Unmute" : "Mute"}
- </Button>
- </div>
-
- {/* Preview Area */}
<div
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
- <div
- ref={previewRef}
- className="relative overflow-hidden rounded-sm bg-black border"
- style={{
- width: previewDimensions.width,
- height: previewDimensions.height,
- }}
- >
- {activeClips.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- {tracks.length === 0
- ? "No media added to timeline"
- : "No clips at current time"}
- </div>
- ) : (
- activeClips.map((clipData, index) => renderClip(clipData, index))
- )}
- </div>
+ {hasAnyClips ? (
+ <div
+ ref={previewRef}
+ className="relative overflow-hidden rounded-sm bg-black border"
+ style={{
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ }}
+ >
+ {activeClips.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
+ No clips at current time
+ </div>
+ ) : (
+ activeClips.map((clipData, index) => renderClip(clipData, index))
+ )}
+ </div>
+ ) : (
+ <>
+ {/* Empty div so toolbar stays at the bottom */}
+ <div className="w-full h-full"></div>
+ </>
+ )}
- <PreviewToolbar />
+ <PreviewToolbar hasAnyClips={hasAnyClips} />
</div>
</div>
);
}
-function PreviewToolbar() {
- const { isPlaying, toggle } = usePlaybackStore();
+function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
+ const { isPlaying, toggle, currentTime } = usePlaybackStore();
+ const {
+ canvasSize,
+ canvasPresets,
+ setCanvasSize,
+ setCanvasSizeFromAspectRatio,
+ } = useEditorStore();
+ const { mediaItems } = useMediaStore();
+ const { tracks, getTotalDuration } = useTimelineStore();
+
+ // Find the current preset based on canvas size
+ const currentPreset = canvasPresets.find(
+ (preset) =>
+ preset.width === canvasSize.width && preset.height === canvasSize.height
+ );
+
+ const handlePresetSelect = (preset: { width: number; height: number }) => {
+ setCanvasSize({ width: preset.width, height: preset.height });
+ };
+
+ // Get the first video/image media item to determine original aspect ratio
+ const getOriginalAspectRatio = () => {
+ // Find first video or image in timeline
+ for (const track of tracks) {
+ for (const clip of track.clips) {
+ const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ if (
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ ) {
+ return getMediaAspectRatio(mediaItem);
+ }
+ }
+ }
+ return 16 / 9; // Default aspect ratio
+ };
+
+ const handleOriginalSelect = () => {
+ const aspectRatio = getOriginalAspectRatio();
+ setCanvasSizeFromAspectRatio(aspectRatio);
+ };
+
+ // Check if current size is "Original" (not matching any preset)
+ const isOriginal = !currentPreset;
return (
<div
data-toolbar
- className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
+ className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full"
>
- <Button variant="text" size="icon" onClick={toggle}>
+ <div>
+ <p
+ className={cn(
+ "text-xs text-muted-foreground tabular-nums",
+ !hasAnyClips && "opacity-50"
+ )}
+ >
+ {formatTimeCode(currentTime, "HH:MM:SS:CS")}/
+ {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
+ </p>
+ </div>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ disabled={!hasAnyClips}
+ >
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
+ <div>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ size="sm"
+ className="!bg-background text-foreground text-xs h-auto rounded-none border border-foreground px-0.5 py-0 font-light"
+ disabled={!hasAnyClips}
+ >
+ {currentPreset?.name || "Ratio"}
+ </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>
+ </div>
</div>
);
}
diff --git a/apps/web/src/components/editor/project-name-editor.tsx b/apps/web/src/components/editor/project-name-editor.tsx
deleted file mode 100644
index aa0141ae..00000000
--- a/apps/web/src/components/editor/project-name-editor.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-"use client";
-
-import { useState, useRef, useEffect } from "react";
-import { Input } from "../ui/input";
-import { useProjectStore } from "@/stores/project-store";
-import { Edit2, Check, X } from "lucide-react";
-import { Button } from "../ui/button";
-
-interface ProjectNameEditorProps {
- className?: string;
-}
-
-export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
- const { activeProject, updateProjectName } = useProjectStore();
- const [isEditing, setIsEditing] = useState(false);
- const [editValue, setEditValue] = useState("");
- const inputRef = useRef<HTMLInputElement>(null);
-
- useEffect(() => {
- if (activeProject) {
- setEditValue(activeProject.name);
- }
- }, [activeProject]);
-
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- const handleStartEdit = () => {
- if (activeProject) {
- setEditValue(activeProject.name);
- setIsEditing(true);
- }
- };
-
- const handleSave = () => {
- if (editValue.trim()) {
- updateProjectName(editValue.trim());
- setIsEditing(false);
- }
- };
-
- const handleCancel = () => {
- if (activeProject) {
- setEditValue(activeProject.name);
- }
- setIsEditing(false);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- handleSave();
- } else if (e.key === "Escape") {
- handleCancel();
- }
- };
-
- if (!activeProject) {
- return <span className="text-sm text-muted-foreground">Loading...</span>;
- }
-
- if (isEditing) {
- return (
- <div className="flex items-center gap-1">
- <Input
- ref={inputRef}
- value={editValue}
- onChange={(e) => setEditValue(e.target.value)}
- onKeyDown={handleKeyDown}
- className="h-7 text-sm px-3 py-1 min-w-[200px]"
- size={1}
- />
- <Button
- size="sm"
- variant="text"
- onClick={handleSave}
- className="h-7 w-7 p-0"
- disabled={!editValue.trim()}
- >
- <Check className="h-3 w-3" />
- </Button>
- <Button
- size="sm"
- variant="text"
- onClick={handleCancel}
- className="h-7 w-7 p-0"
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- );
- }
-
- return (
- <div className="flex items-center gap-1 group">
- <span className="text-sm font-medium">{activeProject.name}</span>
- <Button
- size="sm"
- variant="text"
- onClick={handleStartEdit}
- className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
- >
- <Edit2 className="h-3 w-3" />
- </Button>
- </div>
- );
-} \ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx
index b9d3b9a6..88767a15 100644
--- a/apps/web/src/components/editor/timeline-clip.tsx
+++ b/apps/web/src/components/editor/timeline-clip.tsx
@@ -34,7 +34,6 @@ export function TimelineClip({
track,
zoomLevel,
isSelected,
- onContextMenu,
onClipMouseDown,
onClipClick,
}: TimelineClipProps) {
@@ -282,9 +281,9 @@ export function TimelineClip({
return (
<div
- className={`absolute top-0 h-full select-none transition-all duration-75 ${
+ className={`absolute top-0 h-full select-none transition-all duration-75${
isBeingDragged ? "z-50" : "z-10"
- } ${isSelected ? "ring-2 ring-primary" : ""}`}
+ }`}
style={{
left: `${clipLeft}px`,
width: `${clipWidth}px`,
@@ -294,25 +293,31 @@ export function TimelineClip({
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
- className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
+ className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackColor(
track.type
- )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
+ )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-primary" : ""} ${
+ isBeingDragged ? "z-50" : "z-10"
+ }`}
onClick={(e) => onClipClick && onClipClick(e, clip)}
onMouseDown={handleClipMouseDown}
- onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
+ onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)}
>
<div className="absolute inset-1 flex items-center p-1">
{renderClipContent()}
</div>
- <div
- className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
- <div
- className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
+ {isSelected && (
+ <>
+ <div
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
+ />
+ <div
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
+ />
+ </>
+ )}
<div className="absolute top-1 right-1">
<DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
new file mode 100644
index 00000000..54c50798
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -0,0 +1,672 @@
+"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { Copy, Scissors, Trash2 } from "lucide-react";
+import { TimelineClip } from "./timeline-clip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import {
+ TimelineTrack,
+ TimelineClip as TypeTimelineClip,
+} from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ moveClipToTrack,
+ updateClipStartTime,
+ addClipToTrack,
+ selectedClips,
+ selectClip,
+ deselectClip,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ } = useTimelineStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the clip is selected
+ if (dragState.clipId && dragState.trackId) {
+ const isSelected = selectedClips.some(
+ (c) =>
+ c.trackId === dragState.trackId && c.clipId === dragState.clipId
+ );
+
+ if (!isSelected) {
+ // Select this clip (replacing other selections) since we're dragging it
+ selectClip(dragState.trackId, dragState.clipId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+ const snappedTime = Math.round(adjustedTime * 10) / 10;
+
+ updateDragTime(snappedTime);
+ };
+
+ const handleMouseUp = () => {
+ if (!dragState.clipId || !dragState.trackId) return;
+
+ const finalTime = dragState.currentTime;
+
+ // Check for overlaps and update position
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingClip = sourceTrack?.clips.find(
+ (c) => c.id === dragState.clipId
+ );
+
+ if (movingClip) {
+ const movingClipDuration =
+ movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const movingClipEnd = finalTime + movingClipDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.clips.some((existingClip) => {
+ if (
+ dragState.trackId === track.id &&
+ existingClip.id === dragState.clipId
+ ) {
+ return false;
+ }
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+ return finalTime < existingEnd && movingClipEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ updateClipStartTime(track.id, dragState.clipId, finalTime);
+ } else {
+ moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
+ requestAnimationFrame(() => {
+ updateClipStartTime(track.id, dragState.clipId!, finalTime);
+ });
+ }
+ }
+ }
+
+ endDragAction();
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.clipId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateClipStartTime,
+ moveClipToTrack,
+ endDragAction,
+ ]);
+
+ const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Handle multi-selection only in mousedown
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ selectClip(track.id, clip.id, true);
+ }
+
+ // Calculate the offset from the left edge of the clip to where the user clicked
+ const clipElement = e.currentTarget as HTMLElement;
+ const clipRect = clipElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - clipRect.left;
+ const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
+
+ startDragAction(
+ clip.id,
+ track.id,
+ e.clientX,
+ clip.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection/deselection
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
+ );
+
+ if (isSelected) {
+ // If clip is selected, deselect it
+ deselectClip(track.id, clip.id);
+ } else {
+ // If clip is not selected, select it (replacing other selections)
+ selectClip(track.id, clip.id, false);
+ }
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Handle both timeline clips and media items
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const { type } = JSON.parse(mediaItemData);
+ const isCompatible =
+ (track.type === "video" &&
+ (type === "video" || type === "image")) ||
+ (track.type === "audio" && type === "audio");
+
+ if (!isCompatible) {
+ e.dataTransfer.dropEffect = "none";
+ return;
+ }
+ }
+ } catch (error) {
+ console.error("Error parsing dropped media item:", error);
+ }
+ }
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-clips-container"
+ ) as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (50 * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const { id } = JSON.parse(mediaItemData);
+ const mediaItem = mediaItems.find((item) => item.id === id);
+ if (mediaItem) {
+ const newClipDuration = mediaItem.duration || 5;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const newClipEnd = snappedTime + newClipDuration;
+
+ wouldOverlap = track.clips.some((existingClip) => {
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+ return snappedTime < existingEnd && newClipEnd > existingStart;
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineClip) {
+ try {
+ const timelineClipData = e.dataTransfer.getData(
+ "application/x-timeline-clip"
+ );
+ if (timelineClipData) {
+ const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingClip = sourceTrack?.clips.find(
+ (c: any) => c.id === clipId
+ );
+
+ if (movingClip) {
+ const movingClipDuration =
+ movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const movingClipEnd = snappedTime + movingClipDuration;
+
+ wouldOverlap = track.clips.some((existingClip) => {
+ if (fromTrackId === track.id && existingClip.id === clipId)
+ return false;
+
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+ return snappedTime < existingEnd && movingClipEnd > existingStart;
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setWouldOverlap(true);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
+ setWouldOverlap(false);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ dragCounterRef.current++;
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ dragCounterRef.current--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+ const currentDropPosition = dropPosition;
+ setDropPosition(null);
+
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-clips-container"
+ ) as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const newStartTime = mouseX / (50 * zoomLevel);
+ const snappedTime = Math.round(newStartTime * 10) / 10;
+
+ try {
+ if (hasTimelineClip) {
+ // Handle timeline clip movement
+ const timelineClipData = e.dataTransfer.getData(
+ "application/x-timeline-clip"
+ );
+ if (!timelineClipData) return;
+
+ const {
+ clipId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineClipData);
+
+ // Find the clip being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingClip = sourceTrack?.clips.find(
+ (c: TypeTimelineClip) => c.id === clipId
+ );
+
+ if (!movingClip) {
+ toast.error("Clip not found");
+ return;
+ }
+
+ // Adjust position based on where user clicked on the clip
+ const adjustedStartTime = snappedTime - clickOffsetTime;
+ const finalStartTime = Math.max(
+ 0,
+ Math.round(adjustedStartTime * 10) / 10
+ );
+
+ // Check for overlaps with existing clips (excluding the moving clip itself)
+ const movingClipDuration =
+ movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const movingClipEnd = finalStartTime + movingClipDuration;
+
+ const hasOverlap = track.clips.some((existingClip) => {
+ // Skip the clip being moved if it's on the same track
+ if (fromTrackId === track.id && existingClip.id === clipId)
+ return false;
+
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+
+ // Check if clips overlap
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move clip here - it would overlap with existing clips"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ updateClipStartTime(track.id, clipId, finalStartTime);
+ } else {
+ // Moving to different track
+ moveClipToTrack(fromTrackId, track.id, clipId);
+ requestAnimationFrame(() => {
+ updateClipStartTime(track.id, clipId, finalStartTime);
+ });
+ }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (!mediaItemData) return;
+
+ const { id, type } = JSON.parse(mediaItemData);
+ const mediaItem = mediaItems.find((item) => item.id === id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ // Check if track type is compatible
+ const isCompatible =
+ (track.type === "video" && (type === "video" || type === "image")) ||
+ (track.type === "audio" && type === "audio");
+
+ if (!isCompatible) {
+ toast.error(`Cannot add ${type} to ${track.type} track`);
+ return;
+ }
+
+ // Check for overlaps with existing clips
+ const newClipDuration = mediaItem.duration || 5;
+ const newClipEnd = snappedTime + newClipDuration;
+
+ const hasOverlap = track.clips.some((existingClip) => {
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+
+ // Check if clips overlap
+ return snappedTime < existingEnd && newClipEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place clip here - it would overlap with existing clips"
+ );
+ return;
+ }
+
+ addClipToTrack(track.id, {
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: snappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on a clip), deselect all clips
+ if (!(e.target as HTMLElement).closest(".timeline-clip")) {
+ const { clearSelectedClips } = useTimelineStore.getState();
+ clearSelectedClips();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ className="h-full relative track-clips-container min-w-full"
+ >
+ {track.clips.length === 0 ? (
+ <div
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
+ >
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop clip here"
+ : "Drop media here"}
+ </div>
+ ) : (
+ <>
+ {track.clips.map((clip) => {
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
+ );
+
+ const handleClipSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { updateClipTrim, addClipToTrack } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime +
+ (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ updateClipTrim(
+ track.id,
+ clip.id,
+ clip.trimStart,
+ clip.trimEnd + (effectiveEnd - splitTime)
+ );
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (split)",
+ duration: clip.duration,
+ startTime: splitTime,
+ trimStart: clip.trimStart + (splitTime - effectiveStart),
+ trimEnd: clip.trimEnd,
+ });
+ toast.success("Clip split successfully");
+ } else {
+ toast.error("Playhead must be within clip to split");
+ }
+ };
+
+ const handleClipDuplicate = () => {
+ const { addClipToTrack } = useTimelineStore.getState();
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (copy)",
+ duration: clip.duration,
+ startTime:
+ clip.startTime +
+ (clip.duration - clip.trimStart - clip.trimEnd) +
+ 0.1,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ });
+ toast.success("Clip duplicated");
+ };
+
+ const handleClipDelete = () => {
+ const { removeClipFromTrack } = useTimelineStore.getState();
+ removeClipFromTrack(track.id, clip.id);
+ toast.success("Clip deleted");
+ };
+
+ return (
+ <ContextMenu key={clip.id}>
+ <ContextMenuTrigger asChild>
+ <div>
+ <TimelineClip
+ clip={clip}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onClipMouseDown={handleClipMouseDown}
+ onClipClick={handleClipClick}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem onClick={handleClipSplit}>
+ <Scissors className="h-4 w-4 mr-2" />
+ Split at Playhead
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleClipDuplicate}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate Clip
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={handleClipDelete}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete Clip
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index d43ae588..3f984f92 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -1,1948 +1,1175 @@
-"use client";
-
-import { ScrollArea } from "../ui/scroll-area";
-import { Button } from "../ui/button";
-import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Volume2,
- VolumeX,
- Pause,
- Play,
-} from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../ui/tooltip";
-import {
- useTimelineStore,
- type TimelineTrack,
- type TimelineClip as TypeTimelineClip,
-} from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useDragClip } from "@/hooks/use-drag-clip";
-import { processMediaFiles } from "@/lib/media-processing";
-import { toast } from "sonner";
-import { useState, useRef, useEffect, useCallback } from "react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
-import { TimelineClip } from "./timeline-clip";
-import { ContextMenuState } from "@/types/timeline";
-
-export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their clips.
- // You can drag media here to add it to your project.
- // Clips can be trimmed, deleted, and moved.
- const {
- tracks,
- addTrack,
- addClipToTrack,
- removeTrack,
- toggleTrackMute,
- removeClipFromTrack,
- getTotalDuration,
- selectedClips,
- clearSelectedClips,
- setSelectedClips,
- updateClipTrim,
- splitClip,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- undo,
- redo,
- } = useTimelineStore();
- const { mediaItems, addMediaItem } = useMediaStore();
- const {
- currentTime,
- duration,
- seek,
- setDuration,
- isPlaying,
- toggle,
- setSpeed,
- speed,
- } = usePlaybackStore();
- const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [zoomLevel, setZoomLevel] = useState(1);
- const dragCounterRef = useRef(0);
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isInTimeline, setIsInTimeline] = useState(false);
-
- // Unified context menu state
- const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
-
- // Marquee selection state
- const [marquee, setMarquee] = useState<{
- startX: number;
- startY: number;
- endX: number;
- endY: number;
- active: boolean;
- additive: boolean;
- } | null>(null);
-
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * 50 * zoomLevel, // Base width from duration
- (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const tracksScrollRef = useRef<HTMLDivElement>(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
-
- // New refs for direct playhead DOM manipulation
- const rulerPlayheadRef = useRef<HTMLDivElement>(null);
- const tracksPlayheadRef = useRef<HTMLDivElement>(null);
-
- // Refs to store initial mouse and scroll positions for drag calculations
- const initialMouseXRef = useRef(0);
- const initialTimelineScrollLeftRef = useRef(0);
-
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
-
- // Close context menu on click elsewhere
- useEffect(() => {
- const handleClick = () => setContextMenu(null);
- if (contextMenu) {
- window.addEventListener("click", handleClick);
- return () => window.removeEventListener("click", handleClick);
- }
- }, [contextMenu]);
-
- // Keyboard event for deleting selected clips
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (
- (e.key === "Delete" || e.key === "Backspace") &&
- selectedClips.length > 0
- ) {
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
- });
- clearSelectedClips();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedClips, removeClipFromTrack, clearSelectedClips]);
-
- // Keyboard event for undo (Cmd+Z)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
- e.preventDefault();
- undo();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [undo]);
-
- // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
- e.preventDefault();
- redo();
- } else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
- e.preventDefault();
- redo();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [redo]);
-
- // Mouse down on timeline background to start marquee
- const handleTimelineMouseDown = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget && e.button === 0) {
- setMarquee({
- startX: e.clientX,
- startY: e.clientY,
- endX: e.clientX,
- endY: e.clientY,
- active: true,
- additive: e.metaKey || e.ctrlKey || e.shiftKey,
- });
- }
- };
-
- // Mouse move to update marquee
- useEffect(() => {
- if (!marquee || !marquee.active) return;
- const handleMouseMove = (e: MouseEvent) => {
- setMarquee(
- (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }
- );
- };
- const handleMouseUp = (e: MouseEvent) => {
- setMarquee(
- (prev) =>
- prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }
- );
- };
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [marquee]);
-
- // On marquee end, select clips in box
- useEffect(() => {
- if (!marquee || marquee.active) return;
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
- const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
- const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
- const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
- // Validation: skip if too small
- if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
- setMarquee(null);
- return;
- }
- // Clamp to timeline bounds
- const clamp = (val: number, min: number, max: number) =>
- Math.max(min, Math.min(max, val));
- const bx1 = clamp(x1, 0, rect.width);
- const bx2 = clamp(x2, 0, rect.width);
- const by1 = clamp(y1, 0, rect.height);
- const by2 = clamp(y2, 0, rect.height);
- let newSelection: { trackId: string; clipId: string }[] = [];
- tracks.forEach((track, trackIdx) => {
- track.clips.forEach((clip) => {
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
- const clipLeft = clip.startTime * 50 * zoomLevel;
- const clipTop = trackIdx * 60;
- const clipBottom = clipTop + 60;
- const clipRight = clipLeft + 60; // Set a fixed width for time display
- if (
- bx1 < clipRight &&
- bx2 > clipLeft &&
- by1 < clipBottom &&
- by2 > clipTop
- ) {
- newSelection.push({ trackId: track.id, clipId: clip.id });
- }
- });
- });
- if (newSelection.length > 0) {
- if (marquee.additive) {
- const selectedSet = new Set(
- selectedClips.map((c) => c.trackId + ":" + c.clipId)
- );
- newSelection = [
- ...selectedClips,
- ...newSelection.filter(
- (c) => !selectedSet.has(c.trackId + ":" + c.clipId)
- ),
- ];
- }
- setSelectedClips(newSelection);
- } else if (!marquee.additive) {
- clearSelectedClips();
- }
- setMarquee(null);
- }, [
- marquee,
- tracks,
- zoomLevel,
- selectedClips,
- setSelectedClips,
- clearSelectedClips,
- ]);
-
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Don't update state for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/clip
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline clip drags - they're handled by track-specific handlers
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- if (hasTimelineClip) {
- return;
- }
-
- const mediaItemData = e.dataTransfer.getData("application/x-media-item");
- if (mediaItemData) {
- // Handle media item drops by creating new tracks
- try {
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
- // Add to video or audio track depending on type
- const trackType = type === "audio" ? "audio" : "video";
- const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- toast.success(`Added ${mediaItem.name} to new ${trackType} track`);
- } catch (error) {
- // Show error if parsing fails
- console.error("Error parsing media item data:", error);
- toast.error("Failed to add media to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- setIsProcessing(true);
- setProgress(0);
- try {
- const processedItems = await processMediaFiles(
- e.dataTransfer.files,
- (p) => setProgress(p)
- );
- for (const processedItem of processedItems) {
- addMediaItem(processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- const trackType =
- processedItem.type === "audio" ? "audio" : "video";
- const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- }
- };
-
- const handleSeekToPosition = (e: React.MouseEvent) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const clickedTime = clickX / (50 * zoomLevel);
- const clampedTime = Math.max(0, Math.min(duration, clickedTime));
-
- seek(clampedTime);
- };
-
- const handleTimelineAreaClick = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) {
- clearSelectedClips();
-
- // Calculate the clicked time position and seek to it
- handleSeekToPosition(e);
- }
- };
-
- const handleWheel = (e: React.WheelEvent) => {
- // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- const delta = e.deltaY > 0 ? -0.05 : 0.05;
- setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
- }
- // Otherwise, allow normal scrolling
- };
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
- setScrubTime(time);
- seek(time); // update video preview in real time
- },
- [duration, zoomLevel, seek]
- );
-
- useEffect(() => {
- if (!isScrubbing) return;
- const onMouseMove = (e: MouseEvent) => handleScrub(e);
- const onMouseUp = (e: MouseEvent) => {
- setIsScrubbing(false);
- if (scrubTime !== null) seek(scrubTime); // finalize seek
- setScrubTime(null);
- };
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [isScrubbing, scrubTime, seek, handleScrub]);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // Action handlers for toolbar
- const handleSplitSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newClipId = splitClip(trackId, clipId, currentTime);
- if (newClipId) splitCount++;
- }
- }
- });
- if (splitCount > 0) {
- toast.success(`Split ${splitCount} clip(s) at playhead`);
- } else {
- toast.error("Playhead must be within selected clips to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- }
- });
- toast.success("Duplicated selected clip(s)");
- };
-
- const handleFreezeSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- // Add a new freeze frame clip at the playhead
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (freeze)",
- duration: 1, // 1 second freeze frame
- startTime: currentTime,
- trimStart: 0,
- trimEnd: clip.duration - 1,
- });
- }
- });
- toast.success("Freeze frame added for selected clip(s)");
- };
- const handleSplitAndKeepLeft = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepLeft(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept left portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
- }
- };
-
- const handleSplitAndKeepRight = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepRight(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept right portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
- }
- };
-
- const handleSeparateAudio = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let separatedCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId);
-
- if (
- clip &&
- track &&
- mediaItem?.type === "video" &&
- track.type === "video"
- ) {
- const audioClipId = separateAudio(trackId, clipId);
- if (audioClipId) separatedCount++;
- }
- });
-
- if (separatedCount > 0) {
- toast.success(`Separated audio from ${separatedCount} video clip(s)`);
- } else {
- toast.error("Select video clips to separate audio");
- }
- };
- const handleDeleteSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
- });
- clearSelectedClips();
- toast.success("Deleted selected clip(s)");
- };
-
- // Prevent explorer zooming in/out when in timeline
- useEffect(() => {
- const preventZoom = (e: WheelEvent) => {
- // if (isInTimeline && (e.ctrlKey || e.metaKey)) {
- if (
- isInTimeline &&
- (e.ctrlKey || e.metaKey) &&
- timelineRef.current?.contains(e.target as Node)
- ) {
- e.preventDefault();
- }
- };
-
- document.addEventListener("wheel", preventZoom, { passive: false });
-
- return () => {
- document.removeEventListener("wheel", preventZoom);
- };
- }, [isInTimeline]);
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- rulerViewport.scrollLeft = tracksViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- rulerViewport.addEventListener("scroll", handleRulerScroll);
- tracksViewport.addEventListener("scroll", handleTracksScroll);
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- };
- }, []);
-
- // --- Playhead auto-scroll effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const playheadPx = playheadPosition * 50 * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMin = 0;
- const scrollMax = rulerViewport.scrollWidth - viewportWidth;
- // Center the playhead if it's not visible (100px buffer)
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- if (
- playheadPx < rulerViewport.scrollLeft + 100 ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
- ) {
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [playheadPosition, duration, zoomLevel]);
-
- const getContextMenuPosition = (x: number, y: number) => {
- const menuWidth = 160;
- const menuHeight = 200;
- const margin = 4;
- const verticalOffset = 80;
-
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- let adjustedX = x + 2;
- let adjustedY = y + 2;
-
- // Horizontal positioning
- if (adjustedX + menuWidth > viewportWidth - margin) {
- adjustedX = x - menuWidth - 2;
- if (adjustedX < margin) {
- adjustedX = viewportWidth - menuWidth - margin;
- }
- }
-
- if (adjustedX < margin) {
- adjustedX = margin;
- }
-
- // Vertical positioning
- if (adjustedY + menuHeight > viewportHeight - margin) {
- adjustedY = y - menuHeight + verticalOffset;
-
- if (adjustedY < margin) {
- adjustedY = viewportHeight - menuHeight - margin;
- }
- }
-
- if (adjustedY < margin) {
- adjustedY = margin;
- }
-
- adjustedX = Math.max(
- margin,
- Math.min(adjustedX, viewportWidth - menuWidth - margin)
- );
- adjustedY = Math.max(
- margin,
- Math.min(adjustedY, viewportHeight - menuHeight - margin)
- );
-
- return { x: adjustedX, y: adjustedY };
- };
-
- return (
- <div
- className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
- {...dragProps}
- onMouseEnter={() => setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- onWheel={handleWheel}
- >
- {/* Toolbar */}
- <div className="border-b flex items-center px-2 py-1 gap-1">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
-
- <div className="w-px h-6 bg-border mx-1" />
-
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
-
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("video");
- addClipToTrack(trackId, {
- mediaId: "test",
- name: "Test Clip",
- duration: 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
-
- <div className="w-px h-6 bg-border mx-1" />
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete clip (Delete)</TooltipContent>
- </Tooltip>
-
- <div className="w-px h-6 bg-border mx-1" />
-
- {/* Speed Control */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Select
- value={speed.toFixed(1)}
- onValueChange={(value) => setSpeed(parseFloat(value))}
- >
- <SelectTrigger className="w-[90px] h-8">
- <SelectValue placeholder="1.0x" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="0.5">0.5x</SelectItem>
- <SelectItem value="1.0">1.0x</SelectItem>
- <SelectItem value="1.5">1.5x</SelectItem>
- <SelectItem value="2.0">2.0x</SelectItem>
- </SelectContent>
- </Select>
- </TooltipTrigger>
- <TooltipContent>Playback Speed</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
-
- {/* Timeline Container */}
- <div className="flex-1 flex flex-col overflow-hidden">
- {/* Timeline Header with Ruler */}
- <div className="flex border-b bg-background sticky top-0 z-10">
- {/* Track Labels Header */}
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
- <span className="text-sm font-medium text-muted-foreground">
- Tracks
- </span>
- <div className="text-xs text-muted-foreground">
- {zoomLevel.toFixed(1)}x
- </div>
- </div>
-
- {/* Timeline Ruler */}
- <div className="flex-1 relative overflow-hidden">
- <ScrollArea className="w-full" ref={rulerScrollRef}>
- <div
- className="relative h-12 bg-muted/30 cursor-pointer"
- style={{
- width: `${dynamicTimelineWidth}px`,
- }}
- onClick={(e) => {
- // Calculate the clicked time position and seek to it
- handleSeekToPosition(e);
- }}
- >
- {/* Time markers */}
- {(() => {
- // Calculate appropriate time interval based on zoom level
- const getTimeInterval = (zoom: number) => {
- const pixelsPerSecond = 50 * zoom;
- if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
- if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
- if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
- if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
- if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
- if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
- return 30; // Every 30s when extremely zoomed out
- };
-
- const interval = getTimeInterval(zoomLevel);
- const markerCount = Math.ceil(duration / interval) + 1;
-
- return Array.from({ length: markerCount }, (_, i) => {
- const time = i * interval;
- if (time > duration) return null;
-
- const isMainMarker =
- time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
-
- return (
- <div
- key={i}
- className={`absolute top-0 bottom-0 ${
- isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
- style={{ left: `${time * 50 * zoomLevel}px` }}
- >
- <span
- className={`absolute top-1 left-1 text-xs ${
- isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
- >
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
- } else if (minutes > 0) {
- return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
- } else if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- } else {
- return `${secs.toFixed(1)}s`;
- }
- };
- return formatTime(time);
- })()}
- </span>
- </div>
- );
- }).filter(Boolean);
- })()}
-
- {/* Playhead in ruler (scrubbable) */}
- <div
- className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize"
- style={{ left: `${playheadPosition * 50 * zoomLevel}px` }}
- onMouseDown={handlePlayheadMouseDown}
- >
- <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
- </div>
- </div>
- </ScrollArea>
- </div>
- </div>
-
- {/* Tracks Area */}
- <div className="flex-1 flex overflow-hidden">
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
- <div className="flex flex-col">
- {tracks.map((track) => (
- <div
- key={track.id}
- className="h-[60px] flex items-center px-3 border-b border-muted/30 bg-background group"
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- >
- <div className="flex items-center flex-1 min-w-0">
- <div
- className={`w-3 h-3 rounded-full flex-shrink-0 ${
- track.type === "video"
- ? "bg-blue-500"
- : track.type === "audio"
- ? "bg-green-500"
- : "bg-purple-500"
- }`}
- />
- <span className="ml-2 text-sm font-medium truncate">
- {track.name}
- </span>
- </div>
- {track.muted && (
- <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
- Muted
- </span>
- )}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Timeline Tracks Content */}
- <div className="flex-1 relative overflow-hidden">
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
- <div
- className="relative flex-1"
- style={{
- height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`,
- width: `${dynamicTimelineWidth}px`,
- }}
- onClick={handleTimelineAreaClick}
- onMouseDown={handleTimelineMouseDown}
- >
- {tracks.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center">
- <div className="text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
- <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
- </div>
- <p className="text-sm text-muted-foreground">
- Drop media here to start
- </p>
- </div>
- </div>
- ) : (
- <>
- {tracks.map((track, index) => (
- <div
- key={track.id}
- className="absolute left-0 right-0 border-b border-muted/30"
- style={{
- top: `${index * 60}px`,
- height: "60px",
- }}
- // Show context menu on right click
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (
- !(e.target as HTMLElement).closest(".timeline-clip")
- ) {
- const { clearSelectedClips } =
- useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- setContextMenu={setContextMenu}
- contextMenu={contextMenu}
- />
- </div>
- ))}
-
- {/* Playhead for tracks area (scrubbable) */}
- {tracks.length > 0 && (
- <div
- className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
- style={{
- left: `${playheadPosition * 50 * zoomLevel}px`,
- height: `${tracks.length * 60}px`,
- }}
- onMouseDown={handlePlayheadMouseDown}
- />
- )}
- </>
- )}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- {isProcessing
- ? `Processing ${progress}%`
- : "Drop media here to add to timeline"}
- </div>
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
-
- {/* Clean Unified Context Menu */}
- {contextMenu && (
- <div
- className="fixed z-50 min-w-[160px] bg-popover border border-border rounded-md shadow-md py-1 text-sm"
- style={{
- left: getContextMenuPosition(contextMenu.x, contextMenu.y).x,
- top: getContextMenuPosition(contextMenu.x, contextMenu.y).y,
- }}
- onContextMenu={(e) => e.preventDefault()}
- >
- {contextMenu.type === "track" ? (
- // Track context menu
- <>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- if (track) toggleTrackMute(track.id);
- setContextMenu(null);
- }}
- >
- {contextMenu.trackId ? (
- <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </div>
- ) : (
- <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </div>
- )}
- </button>
- <div className="h-px bg-border mx-1 my-1" />
- <button
- className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
- onClick={() => {
- removeTrack(contextMenu.trackId);
- setContextMenu(null);
- toast.success("Track deleted");
- }}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Track
- </button>
- </>
- ) : (
- // Clip context menu
- <>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- const clip = track?.clips.find(
- (c) => c.id === contextMenu.clipId
- );
- if (clip && track) {
- const splitTime = currentTime;
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (
- splitTime > effectiveStart &&
- splitTime < effectiveEnd
- ) {
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + (effectiveEnd - splitTime)
- );
- useTimelineStore.getState().addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (split)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart:
- clip.trimStart + (splitTime - effectiveStart),
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip split successfully");
- } else {
- toast.error("Playhead must be within clip to split");
- }
- }
- }
- setContextMenu(null);
- }}
- >
- <Scissors className="h-4 w-4 mr-2" />
- Split at Playhead
- </button>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- const clip = track?.clips.find(
- (c) => c.id === contextMenu.clipId
- );
- if (clip && track) {
- useTimelineStore.getState().addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip duplicated");
- }
- }
- setContextMenu(null);
- }}
- >
- <Copy className="h-4 w-4 mr-2" />
- Duplicate Clip
- </button>
- <div className="h-px bg-border mx-1 my-1" />
- <button
- className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- removeClipFromTrack(
- contextMenu.trackId,
- contextMenu.clipId
- );
- toast.success("Clip deleted");
- }
- setContextMenu(null);
- }}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Clip
- </button>
- </>
- )}
- </div>
- )}
- </div>
- );
-}
-
-function TimelineTrackContent({
- track,
- zoomLevel,
- setContextMenu,
- contextMenu,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- setContextMenu: (menu: ContextMenuState | null) => void;
- contextMenu: ContextMenuState | null;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- moveClipToTrack,
- updateClipStartTime,
- addClipToTrack,
- selectedClips,
- selectClip,
- deselectClip,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- } = useTimelineStore();
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
-
- updateDragTime(snappedTime);
- };
-
- const handleMouseUp = () => {
- if (!dragState.clipId || !dragState.trackId) return;
-
- const finalTime = dragState.currentTime;
-
- // Check for overlaps and update position
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingClip = sourceTrack?.clips.find(
- (c) => c.id === dragState.clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalTime + movingClipDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.clips.some((existingClip) => {
- if (
- dragState.trackId === track.id &&
- existingClip.id === dragState.clipId
- ) {
- return false;
- }
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return finalTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- updateClipStartTime(track.id, dragState.clipId, finalTime);
- } else {
- moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, dragState.clipId!, finalTime);
- });
- }
- }
- }
-
- endDragAction();
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.clipId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateClipStartTime,
- moveClipToTrack,
- endDragAction,
- ]);
-
- const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
- // Handle multi-selection only in mousedown
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- selectClip(track.id, clip.id, true);
- }
-
- // Calculate the offset from the left edge of the clip to where the user clicked
- const clipElement = e.currentTarget as HTMLElement;
- const clipRect = clipElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - clipRect.left;
- const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
-
- startDragAction(
- clip.id,
- track.id,
- e.clientX,
- clip.startTime,
- clickOffsetTime
- );
- };
-
- const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Close context menu if it's open
- if (contextMenu) {
- setContextMenu(null);
- return; // Don't handle selection when closing context menu
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection/deselection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (isSelected) {
- // If clip is selected, deselect it
- deselectClip(track.id, clip.id);
- } else {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- };
-
- const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => {
- e.preventDefault();
- e.stopPropagation();
- setContextMenu({
- type: "clip",
- trackId: track.id,
- clipId: clipId,
- x: e.clientX,
- y: e.clientY,
- });
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline clips and media items
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { type } = JSON.parse(mediaItemData);
- const isCompatible =
- (track.type === "video" &&
- (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- e.dataTransfer.dropEffect = "none";
- return;
- }
- }
- } catch (error) {
- console.error("Error parsing dropped media item:", error);
- }
- }
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (50 * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const { id } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (mediaItem) {
- const newClipDuration = mediaItem.duration || 5;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const newClipEnd = snappedTime + newClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineClip) {
- try {
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (timelineClipData) {
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: any) => c.id === clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const snappedTime = Math.round(dropTime * 10) / 10;
- const movingClipEnd = snappedTime + movingClipDuration;
-
- wouldOverlap = track.clips.some((existingClip) => {
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return snappedTime < existingEnd && movingClipEnd > existingStart;
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- setDropPosition(Math.round(dropTime * 10) / 10);
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setWouldOverlap(false);
- setDropPosition(Math.round(dropTime * 10) / 10);
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
- const currentDropPosition = dropPosition;
- setDropPosition(null);
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const newStartTime = mouseX / (50 * zoomLevel);
- const snappedTime = Math.round(newStartTime * 10) / 10;
-
- try {
- if (hasTimelineClip) {
- // Handle timeline clip movement
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (!timelineClipData) return;
-
- const {
- clipId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineClipData);
-
- // Find the clip being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: TypeTimelineClip) => c.id === clipId
- );
-
- if (!movingClip) {
- toast.error("Clip not found");
- return;
- }
-
- // Adjust position based on where user clicked on the clip
- const adjustedStartTime = snappedTime - clickOffsetTime;
- const finalStartTime = Math.max(
- 0,
- Math.round(adjustedStartTime * 10) / 10
- );
-
- // Check for overlaps with existing clips (excluding the moving clip itself)
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalStartTime + movingClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- // Skip the clip being moved if it's on the same track
- if (fromTrackId === track.id && existingClip.id === clipId)
- return false;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return finalStartTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move clip here - it would overlap with existing clips"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- updateClipStartTime(track.id, clipId, finalStartTime);
- } else {
- // Moving to different track
- moveClipToTrack(fromTrackId, track.id, clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, finalStartTime);
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- // Check if track type is compatible
- const isCompatible =
- (track.type === "video" && (type === "video" || type === "image")) ||
- (track.type === "audio" && type === "audio");
-
- if (!isCompatible) {
- toast.error(`Cannot add ${type} to ${track.type} track`);
- return;
- }
-
- // Check for overlaps with existing clips
- const newClipDuration = mediaItem.duration || 5;
- const newClipEnd = snappedTime + newClipDuration;
-
- const hasOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place clip here - it would overlap with existing clips"
- );
- return;
- }
-
- addClipToTrack(track.id, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: snappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
-
- toast.success(`Added ${mediaItem.name} to ${track.name}`);
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onContextMenu={(e) => {
- e.preventDefault();
- // Only show track menu if we didn't click on a clip
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }
- }}
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- const { clearSelectedClips } = useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-clips-container min-w-full"
- >
- {track.clips.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop clip here"
- : "Drop media here"}
- </div>
- ) : (
- <>
- {track.clips.map((clip) => {
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- return (
- <TimelineClip
- key={clip.id}
- clip={clip}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onContextMenu={handleClipContextMenu}
- onClipMouseDown={handleClipMouseDown}
- onClipClick={handleClipClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client";
+
+import { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
+import {
+ Scissors,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Trash2,
+ Snowflake,
+ Copy,
+ SplitSquareHorizontal,
+ Volume2,
+ VolumeX,
+ Pause,
+ Play,
+} from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { processMediaFiles } from "@/lib/media-processing";
+import { toast } from "sonner";
+import { useState, useRef, useEffect, useCallback } from "react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { TimelineTrackContent } from "./timeline-track";
+
+export function Timeline() {
+ // Timeline shows all tracks (video, audio, effects) and their clips.
+ // You can drag media here to add it to your project.
+ // Clips can be trimmed, deleted, and moved.
+ const {
+ tracks,
+ addTrack,
+ addClipToTrack,
+ removeTrack,
+ toggleTrackMute,
+ removeClipFromTrack,
+ getTotalDuration,
+ selectedClips,
+ clearSelectedClips,
+ setSelectedClips,
+ splitClip,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ undo,
+ redo,
+ } = useTimelineStore();
+ const { mediaItems, addMediaItem } = useMediaStore();
+ const {
+ currentTime,
+ duration,
+ seek,
+ setDuration,
+ isPlaying,
+ toggle,
+ setSpeed,
+ speed,
+ } = usePlaybackStore();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [zoomLevel, setZoomLevel] = useState(1);
+ const dragCounterRef = useRef(0);
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isInTimeline, setIsInTimeline] = useState(false);
+
+ // Marquee selection state
+ const [marquee, setMarquee] = useState<{
+ startX: number;
+ startY: number;
+ endX: number;
+ endY: number;
+ active: boolean;
+ additive: boolean;
+ } | null>(null);
+
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
+
+ // Add new state for ruler drag detection
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+
+ // Dynamic timeline width calculation based on playhead position and duration
+ const dynamicTimelineWidth = Math.max(
+ (duration || 0) * 50 * zoomLevel, // Base width from duration
+ (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer
+ timelineRef.current?.clientWidth || 1000 // Minimum width
+ );
+
+ // Scroll synchronization and auto-scroll to playhead
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+
+ // New refs for direct playhead DOM manipulation
+ const rulerPlayheadRef = useRef<HTMLDivElement>(null);
+ const tracksPlayheadRef = useRef<HTMLDivElement>(null);
+
+ // Refs to store initial mouse and scroll positions for drag calculations
+ const initialMouseXRef = useRef(0);
+ const initialTimelineScrollLeftRef = useRef(0);
+
+ // Update timeline duration when tracks change
+ useEffect(() => {
+ const totalDuration = getTotalDuration();
+ setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
+ }, [tracks, setDuration, getTotalDuration]);
+
+ // Keyboard event for deleting selected clips
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ (e.key === "Delete" || e.key === "Backspace") &&
+ selectedClips.length > 0
+ ) {
+ selectedClips.forEach(({ trackId, clipId }) => {
+ removeClipFromTrack(trackId, clipId);
+ });
+ clearSelectedClips();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [selectedClips, removeClipFromTrack, clearSelectedClips]);
+
+ // Keyboard event for undo (Cmd+Z)
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
+ e.preventDefault();
+ undo();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [undo]);
+
+ // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
+ e.preventDefault();
+ redo();
+ } else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
+ e.preventDefault();
+ redo();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [redo]);
+
+ // Mouse down on timeline background to start marquee
+ const handleTimelineMouseDown = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget && e.button === 0) {
+ setMarquee({
+ startX: e.clientX,
+ startY: e.clientY,
+ endX: e.clientX,
+ endY: e.clientY,
+ active: true,
+ additive: e.metaKey || e.ctrlKey || e.shiftKey,
+ });
+ }
+ };
+
+ // Mouse move to update marquee
+ useEffect(() => {
+ if (!marquee || !marquee.active) return;
+ const handleMouseMove = (e: MouseEvent) => {
+ setMarquee(
+ (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }
+ );
+ };
+ const handleMouseUp = (e: MouseEvent) => {
+ setMarquee(
+ (prev) =>
+ prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }
+ );
+ };
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [marquee]);
+
+ // On marquee end, select clips in box
+ useEffect(() => {
+ if (!marquee || marquee.active) return;
+ const timeline = timelineRef.current;
+ if (!timeline) return;
+ const rect = timeline.getBoundingClientRect();
+ const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
+ const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
+ const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
+ const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
+ // Validation: skip if too small
+ if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
+ setMarquee(null);
+ return;
+ }
+ // Clamp to timeline bounds
+ const clamp = (val: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, val));
+ const bx1 = clamp(x1, 0, rect.width);
+ const bx2 = clamp(x2, 0, rect.width);
+ const by1 = clamp(y1, 0, rect.height);
+ const by2 = clamp(y2, 0, rect.height);
+ let newSelection: { trackId: string; clipId: string }[] = [];
+ tracks.forEach((track, trackIdx) => {
+ track.clips.forEach((clip) => {
+ const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
+ const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
+ const clipLeft = clip.startTime * 50 * zoomLevel;
+ const clipTop = trackIdx * 60;
+ const clipBottom = clipTop + 60;
+ const clipRight = clipLeft + 60; // Set a fixed width for time display
+ if (
+ bx1 < clipRight &&
+ bx2 > clipLeft &&
+ by1 < clipBottom &&
+ by2 > clipTop
+ ) {
+ newSelection.push({ trackId: track.id, clipId: clip.id });
+ }
+ });
+ });
+ if (newSelection.length > 0) {
+ if (marquee.additive) {
+ const selectedSet = new Set(
+ selectedClips.map((c) => c.trackId + ":" + c.clipId)
+ );
+ newSelection = [
+ ...selectedClips,
+ ...newSelection.filter(
+ (c) => !selectedSet.has(c.trackId + ":" + c.clipId)
+ ),
+ ];
+ }
+ setSelectedClips(newSelection);
+ } else if (!marquee.additive) {
+ clearSelectedClips();
+ }
+ setMarquee(null);
+ }, [
+ marquee,
+ tracks,
+ zoomLevel,
+ selectedClips,
+ setSelectedClips,
+ clearSelectedClips,
+ ]);
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ // When something is dragged over the timeline, show overlay
+ e.preventDefault();
+ // Don't show overlay for timeline clips - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+ dragCounterRef.current += 1;
+ if (!isDragOver) {
+ setIsDragOver(true);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Don't update state for timeline clips - they're handled by tracks
+ if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ return;
+ }
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ // When media is dropped, add it as a new track/clip
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ // Ignore timeline clip drags - they're handled by track-specific handlers
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ if (hasTimelineClip) {
+ return;
+ }
+
+ const mediaItemData = e.dataTransfer.getData("application/x-media-item");
+ if (mediaItemData) {
+ // Handle media item drops by creating new tracks
+ try {
+ const { id, type } = JSON.parse(mediaItemData);
+ const mediaItem = mediaItems.find((item) => item.id === id);
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+ // Add to video or audio track depending on type
+ const trackType = type === "audio" ? "audio" : "video";
+ const newTrackId = addTrack(trackType);
+ addClipToTrack(newTrackId, {
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ } catch (error) {
+ // Show error if parsing fails
+ console.error("Error parsing media item data:", error);
+ toast.error("Failed to add media to timeline");
+ }
+ } else if (e.dataTransfer.files?.length > 0) {
+ // Handle file drops by creating new tracks
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
+ for (const processedItem of processedItems) {
+ await addMediaItem(processedItem);
+ const currentMediaItems = useMediaStore.getState().mediaItems;
+ const addedItem = currentMediaItems.find(
+ (item) =>
+ item.name === processedItem.name && item.url === processedItem.url
+ );
+ if (addedItem) {
+ const trackType =
+ processedItem.type === "audio" ? "audio" : "video";
+ const newTrackId = addTrack(trackType);
+ addClipToTrack(newTrackId, {
+ mediaId: addedItem.id,
+ name: addedItem.name,
+ duration: addedItem.duration || 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ // Show error if file processing fails
+ console.error("Error processing external files:", error);
+ toast.error("Failed to process dropped files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ }
+ };
+
+ const handleWheel = (e: React.WheelEvent) => {
+ // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.05 : 0.05;
+ setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
+ }
+ // Otherwise, allow normal scrolling
+ };
+
+ // --- Playhead Scrubbing Handlers ---
+ const handlePlayheadMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent ruler drag from triggering
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ // Add new ruler mouse down handler
+ const handleRulerMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ // Only handle left mouse button
+ if (e.button !== 0) return;
+
+ // Don't interfere if clicking on the playhead itself
+ if ((e.target as HTMLElement).closest(".playhead")) return;
+
+ e.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
+
+ // Start scrubbing immediately
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ const handleScrub = useCallback(
+ (e: MouseEvent | React.MouseEvent) => {
+ const timeline = timelineRef.current;
+ if (!timeline) return;
+ const rect = timeline.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ setScrubTime(time);
+ seek(time); // update video preview in real time
+ },
+ [duration, zoomLevel, seek]
+ );
+
+ useEffect(() => {
+ if (!isScrubbing) return;
+ const onMouseMove = (e: MouseEvent) => {
+ handleScrub(e);
+ // Mark that we've dragged if ruler drag is active
+ if (isDraggingRuler) {
+ setHasDraggedRuler(true);
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ setIsScrubbing(false);
+ if (scrubTime !== null) seek(scrubTime); // finalize seek
+ setScrubTime(null);
+
+ // Handle ruler click vs drag
+ if (isDraggingRuler) {
+ setIsDraggingRuler(false);
+ // If we didn't drag, treat it as a click-to-seek
+ if (!hasDraggedRuler) {
+ handleScrub(e);
+ }
+ setHasDraggedRuler(false);
+ }
+ };
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [
+ isScrubbing,
+ scrubTime,
+ seek,
+ handleScrub,
+ isDraggingRuler,
+ hasDraggedRuler,
+ ]);
+
+ const playheadPosition =
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
+
+ const dragProps = {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ };
+
+ // Action handlers for toolbar
+ const handleSplitSelected = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+ let splitCount = 0;
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ if (clip && track) {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newClipId = splitClip(trackId, clipId, currentTime);
+ if (newClipId) splitCount++;
+ }
+ }
+ });
+ if (splitCount > 0) {
+ toast.success(`Split ${splitCount} clip(s) at playhead`);
+ } else {
+ toast.error("Playhead must be within selected clips to split");
+ }
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ if (clip && track) {
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (copy)",
+ duration: clip.duration,
+ startTime:
+ clip.startTime +
+ (clip.duration - clip.trimStart - clip.trimEnd) +
+ 0.1,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ });
+ }
+ });
+ toast.success("Duplicated selected clip(s)");
+ };
+
+ const handleFreezeSelected = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ if (clip && track) {
+ // Add a new freeze frame clip at the playhead
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (freeze)",
+ duration: 1, // 1 second freeze frame
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: clip.duration - 1,
+ });
+ }
+ });
+ };
+ const handleSplitAndKeepLeft = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+
+ let splitCount = 0;
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ if (clip && track) {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ splitAndKeepLeft(trackId, clipId, currentTime);
+ splitCount++;
+ }
+ }
+ });
+
+ if (splitCount > 0) {
+ toast.success(`Split and kept left portion of ${splitCount} clip(s)`);
+ } else {
+ toast.error("Playhead must be within selected clips");
+ }
+ };
+
+ const handleSplitAndKeepRight = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+
+ let splitCount = 0;
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ if (clip && track) {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ splitAndKeepRight(trackId, clipId, currentTime);
+ splitCount++;
+ }
+ }
+ });
+
+ if (splitCount > 0) {
+ toast.success(`Split and kept right portion of ${splitCount} clip(s)`);
+ } else {
+ toast.error("Playhead must be within selected clips");
+ }
+ };
+
+ const handleSeparateAudio = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+
+ let separatedCount = 0;
+ selectedClips.forEach(({ trackId, clipId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+ const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId);
+
+ if (
+ clip &&
+ track &&
+ mediaItem?.type === "video" &&
+ track.type === "video"
+ ) {
+ const audioClipId = separateAudio(trackId, clipId);
+ if (audioClipId) separatedCount++;
+ }
+ });
+
+ if (separatedCount > 0) {
+ toast.success(`Separated audio from ${separatedCount} video clip(s)`);
+ } else {
+ toast.error("Select video clips to separate audio");
+ }
+ };
+ const handleDeleteSelected = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ return;
+ }
+ selectedClips.forEach(({ trackId, clipId }) => {
+ removeClipFromTrack(trackId, clipId);
+ });
+ clearSelectedClips();
+ toast.success("Deleted selected clip(s)");
+ };
+
+ // Prevent explorer zooming in/out when in timeline
+ useEffect(() => {
+ const preventZoom = (e: WheelEvent) => {
+ // if (isInTimeline && (e.ctrlKey || e.metaKey)) {
+ if (
+ isInTimeline &&
+ (e.ctrlKey || e.metaKey) &&
+ timelineRef.current?.contains(e.target as Node)
+ ) {
+ e.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, { passive: false });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom);
+ };
+ }, [isInTimeline]);
+
+ // --- Scroll synchronization effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerViewport || !tracksViewport) return;
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft;
+ isUpdatingRef.current = false;
+ };
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ };
+ }, []);
+
+ // --- Playhead auto-scroll effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerViewport || !tracksViewport) return;
+ const playheadPx = playheadPosition * 50 * zoomLevel;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMin = 0;
+ const scrollMax = rulerViewport.scrollWidth - viewportWidth;
+ // Center the playhead if it's not visible (100px buffer)
+ const desiredScroll = Math.max(
+ scrollMin,
+ Math.min(scrollMax, playheadPx - viewportWidth / 2)
+ );
+ if (
+ playheadPx < rulerViewport.scrollLeft + 100 ||
+ playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
+ ) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }, [playheadPosition, duration, zoomLevel]);
+
+ return (
+ <div
+ className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
+ {...dragProps}
+ onMouseEnter={() => setIsInTimeline(true)}
+ onMouseLeave={() => setIsInTimeline(false)}
+ onWheel={handleWheel}
+ >
+ {/* Toolbar */}
+ <div className="border-b flex items-center px-2 py-1 gap-1">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("video");
+ addClipToTrack(trackId, {
+ mediaId: "test",
+ name: "Test Clip",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete clip (Delete)</TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Speed Control */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Select
+ value={speed.toFixed(1)}
+ onValueChange={(value) => setSpeed(parseFloat(value))}
+ >
+ <SelectTrigger className="w-[90px] h-8">
+ <SelectValue placeholder="1.0x" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="0.5">0.5x</SelectItem>
+ <SelectItem value="1.0">1.0x</SelectItem>
+ <SelectItem value="1.5">1.5x</SelectItem>
+ <SelectItem value="2.0">2.0x</SelectItem>
+ </SelectContent>
+ </Select>
+ </TooltipTrigger>
+ <TooltipContent>Playback Speed</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+
+ {/* Timeline Container */}
+ <div className="flex-1 flex flex-col overflow-hidden">
+ {/* Timeline Header with Ruler */}
+ <div className="flex border-b bg-background sticky top-0 z-10">
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ <span className="text-sm font-medium text-muted-foreground">
+ Tracks
+ </span>
+ <div className="text-xs text-muted-foreground">
+ {zoomLevel.toFixed(1)}x
+ </div>
+ </div>
+
+ {/* Timeline Ruler */}
+ <div className="flex-1 relative overflow-hidden">
+ <ScrollArea className="w-full" ref={rulerScrollRef}>
+ <div
+ ref={timelineRef}
+ className={`relative h-12 bg-muted/30 select-none ${
+ isDraggingRuler ? "cursor-grabbing" : "cursor-grab"
+ }`}
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond = 50 * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker =
+ time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
+ style={{ left: `${time * 50 * zoomLevel}px` }}
+ >
+ <span
+ className={`absolute top-1 left-1 text-xs ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+
+ {/* Playhead in ruler (scrubbable) */}
+ <div
+ className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize"
+ style={{ left: `${playheadPosition * 50 * zoomLevel}px` }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
+ </div>
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ {tracks.length > 0 && (
+ <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
+ <div className="flex flex-col">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="h-[60px] flex items-center px-3 border-b border-muted/30 bg-background group"
+ >
+ <div className="flex items-center flex-1 min-w-0">
+ <div
+ className={`w-3 h-3 rounded-full flex-shrink-0 ${
+ track.type === "video"
+ ? "bg-blue-500"
+ : track.type === "audio"
+ ? "bg-green-500"
+ : "bg-purple-500"
+ }`}
+ />
+ <span className="ml-2 text-sm font-medium truncate">
+ {track.name}
+ </span>
+ </div>
+ {track.muted && (
+ <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
+ Muted
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Timeline Tracks Content */}
+ <div className="flex-1 relative overflow-hidden">
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <div
+ className="relative flex-1"
+ style={{
+ height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onClick={handleTimelineMouseDown}
+ onMouseDown={handleTimelineMouseDown}
+ >
+ {tracks.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <div className="text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
+ <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">
+ Drop media here to start
+ </p>
+ </div>
+ </div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute left-0 right-0 border-b border-muted/30"
+ style={{
+ top: `${index * 60}px`,
+ height: "60px",
+ }}
+ onClick={(e) => {
+ // If clicking empty area (not on a clip), deselect all clips
+ if (
+ !(e.target as HTMLElement).closest(
+ ".timeline-clip"
+ )
+ ) {
+ clearSelectedClips();
+ }
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ onClick={() => {
+ toggleTrackMute(track.id);
+ }}
+ >
+ {track.muted ? (
+ <>
+ <Volume2 className="h-4 w-4 mr-2" />
+ Unmute Track
+ </>
+ ) : (
+ <>
+ <VolumeX className="h-4 w-4 mr-2" />
+ Mute Track
+ </>
+ )}
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={() => {
+ removeTrack(track.id);
+ toast.success("Track deleted");
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete Track
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+
+ {/* Playhead for tracks area (scrubbable) */}
+ {tracks.length > 0 && (
+ <div
+ className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col"
+ style={{
+ left: `${playheadPosition * 50 * zoomLevel}px`,
+ height: `${tracks.length * 60}px`,
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ />
+ )}
+ </>
+ )}
+ {isDragOver && (
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 7cc1440f..ea2c33e8 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -25,12 +25,12 @@ export function Footer() {
return (
<motion.footer
- className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
+ className="bg-background border-t"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
- <div className="max-w-5xl mx-auto px-4 py-10">
+ <div className="max-w-5xl mx-auto px-8 py-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index ff15774e..56317c08 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -41,7 +41,7 @@ export function Header() {
</Button>
</Link>
{process.env.NODE_ENV === "development" ? (
- <Link href="/editor">
+ <Link href="/editor/1">
<Button size="sm" className="text-sm ml-4">
Editor
<ArrowRight className="h-4 w-4" />
@@ -61,7 +61,7 @@ export function Header() {
return (
<div className="mx-4 md:mx-0">
<HeaderBase
- className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 1ac9390f..aac63e94 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -69,7 +69,14 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
- <div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
+ <Image
+ className="absolute top-0 left-0 -z-50 size-full object-cover"
+ src="/landing-page-bg.png"
+ height={1903.5}
+ width={1269}
+ alt="landing-page.bg"
+ />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx
new file mode 100644
index 00000000..d84bad71
--- /dev/null
+++ b/apps/web/src/components/rename-project-dialog.tsx
@@ -0,0 +1,73 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function RenameProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ projectName,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (name: string) => void;
+ projectName: string;
+}) {
+ const [name, setName] = useState(projectName);
+
+ // Reset the name when dialog opens - this is better UX than syncing with every prop change
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(projectName);
+ }
+ onOpenChange(open);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Rename Project</DialogTitle>
+ <DialogDescription>
+ Enter a new name for your project.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="Enter a new name"
+ className="mt-4"
+ />
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={() => onConfirm(name)}>Rename</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx
new file mode 100644
index 00000000..e007ca96
--- /dev/null
+++ b/apps/web/src/components/storage-provider.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+import { useProjectStore } from "@/stores/project-store";
+import { useMediaStore } from "@/stores/media-store";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
+
+interface StorageContextType {
+ isInitialized: boolean;
+ isLoading: boolean;
+ hasSupport: boolean;
+ error: string | null;
+}
+
+const StorageContext = createContext<StorageContextType | null>(null);
+
+export function useStorage() {
+ const context = useContext(StorageContext);
+ if (!context) {
+ throw new Error("useStorage must be used within StorageProvider");
+ }
+ return context;
+}
+
+interface StorageProviderProps {
+ children: React.ReactNode;
+}
+
+export function StorageProvider({ children }: StorageProviderProps) {
+ const [status, setStatus] = useState<StorageContextType>({
+ isInitialized: false,
+ isLoading: true,
+ hasSupport: false,
+ error: null,
+ });
+
+ const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
+ const loadAllMedia = useMediaStore((state) => state.loadAllMedia);
+
+ useEffect(() => {
+ const initializeStorage = async () => {
+ setStatus((prev) => ({ ...prev, isLoading: true }));
+
+ try {
+ // Check browser support
+ const hasSupport = storageService.isFullySupported();
+
+ if (!hasSupport) {
+ toast.warning(
+ "Storage not fully supported. Some features may not work."
+ );
+ }
+
+ // Load saved data in parallel
+ await Promise.all([loadAllProjects(), loadAllMedia()]);
+
+ setStatus({
+ isInitialized: true,
+ isLoading: false,
+ hasSupport,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Failed to initialize storage:", error);
+ setStatus({
+ isInitialized: false,
+ isLoading: false,
+ hasSupport: storageService.isFullySupported(),
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ };
+
+ initializeStorage();
+ }, [loadAllProjects, loadAllMedia]);
+
+ return (
+ <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
+ );
+}
diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx
index 845bc9c8..c5ca9d9f 100644
--- a/apps/web/src/components/ui/card.tsx
+++ b/apps/web/src/components/ui/card.tsx
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
- "rounded-xl border bg-card text-card-foreground shadow",
+ "rounded-xl border bg-card text-card-foreground",
className
)}
{...props}
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index defa0755..7aa938f6 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -3,6 +3,7 @@
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
@@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+const contextMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "focus:opacity-65 focus:text-accent-foreground",
+ destructive: "text-destructive focus:text-destructive/80",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
->(({ className, inset, children, ...props }, ref) => (
+>(({ className, inset, children, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
+ contextMenuItemVariants({ variant }),
+ "data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8",
className
)}
{...props}
>
{children}
- <ChevronRight className="ml-auto h-4 w-4" />
+ <ChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
->(({ className, inset, ...props }, ref) => (
+>(({ className, inset, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
- "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ contextMenuItemVariants({ variant }),
inset && "pl-8",
className
)}
@@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
->(({ className, children, checked, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ }
+>(({ className, children, checked, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
- className={cn(
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
- )}
+ className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
checked={checked}
{...props}
>
@@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
- className={cn(
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
- )}
+ className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
- <Circle className="h-4 w-4 fill-current" />
+ <Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
- "px-2 py-1.5 text-sm font-semibold text-foreground",
+ "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
@@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
- className={cn("-mx-1 my-1 h-px bg-border", className)}
+ className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));
@@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
- className={cn(
- "ml-auto text-xs tracking-widest text-muted-foreground",
- className
- )}
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 2b653e3b..9db8a268 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -19,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+const dropdownMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "focus:opacity-65 focus:text-accent-foreground",
+ destructive: "text-destructive focus:text-destructive/80",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
->(({ className, inset, children, ...props }, ref) => (
+>(({ className, inset, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
- "flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ dropdownMenuItemVariants({ variant }),
+ "data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8",
className
)}
@@ -66,7 +83,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
@@ -76,22 +93,6 @@ const DropdownMenuContent = React.forwardRef<
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-const dropdownMenuItemVariants = cva(
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "focus:bg-accent focus:text-accent-foreground",
- destructive:
- "text-destructive focus:bg-destructive focus:text-destructive-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-);
-
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
@@ -113,12 +114,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
->(({ className, children, checked, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, checked, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ dropdownMenuItemVariants({ variant }),
+ "pl-8 pr-2",
className
)}
checked={checked}
@@ -137,12 +141,15 @@ DropdownMenuCheckboxItem.displayName =
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ dropdownMenuItemVariants({ variant }),
+ "pl-8 pr-2",
className
)}
{...props}
@@ -181,7 +188,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
- className={cn("-mx-1 my-1 h-px bg-muted", className)}
+ className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index a0f5f7b2..4332dc6b 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -3,6 +3,7 @@
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
@@ -12,6 +13,21 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
+const selectItemVariants = cva(
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "focus:opacity-65 focus:text-accent-foreground",
+ destructive: "text-destructive focus:text-destructive/80",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
@@ -113,14 +129,13 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
+ variant?: VariantProps<typeof selectItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
- className={cn(
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
- )}
+ className={cn(selectItemVariants({ variant }), className)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
@@ -139,7 +154,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
- className={cn("-mx-1 my-1 h-px bg-muted", className)}
+ className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index daf22b34..4c1294ae 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -1,81 +1,85 @@
-import { toast } from "sonner";
-import {
- getFileType,
- generateVideoThumbnail,
- getMediaDuration,
- getImageAspectRatio,
- type MediaItem,
-} from "@/stores/media-store";
-// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
-
-export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
-
-export async function processMediaFiles(
- files: FileList | File[],
- onProgress?: (progress: number) => void
-): Promise<ProcessedMediaItem[]> {
- const fileArray = Array.from(files);
- const processedItems: ProcessedMediaItem[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getFileType(file);
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- continue;
- }
-
- const url = URL.createObjectURL(file);
- let thumbnailUrl: string | undefined;
- let duration: number | undefined;
- let aspectRatio: number = 16 / 9; // Default fallback
-
- try {
- if (fileType === "image") {
- // Get image aspect ratio
- aspectRatio = await getImageAspectRatio(file);
- } else if (fileType === "video") {
- // Use basic thumbnail generation for now
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- aspectRatio = videoResult.aspectRatio;
- } else if (fileType === "audio") {
- // For audio, use a square aspect ratio
- aspectRatio = 1;
- }
-
- // Get duration for videos and audio (if not already set by FFmpeg)
- if ((fileType === "video" || fileType === "audio") && !duration) {
- duration = await getMediaDuration(file);
- }
-
- processedItems.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- aspectRatio,
- });
-
- // Yield back to the event loop to keep the UI responsive
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress(percent);
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedItems;
-}
+import { toast } from "sonner";
+import {
+ getFileType,
+ generateVideoThumbnail,
+ getMediaDuration,
+ getImageDimensions,
+ type MediaItem,
+} from "@/stores/media-store";
+// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
+
+export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
+
+export async function processMediaFiles(
+ files: FileList | File[],
+ onProgress?: (progress: number) => void
+): Promise<ProcessedMediaItem[]> {
+ const fileArray = Array.from(files);
+ const processedItems: ProcessedMediaItem[] = [];
+
+ const total = fileArray.length;
+ let completed = 0;
+
+ for (const file of fileArray) {
+ const fileType = getFileType(file);
+
+ if (!fileType) {
+ toast.error(`Unsupported file type: ${file.name}`);
+ continue;
+ }
+
+ const url = URL.createObjectURL(file);
+ let thumbnailUrl: string | undefined;
+ let duration: number | undefined;
+ let width: number | undefined;
+ let height: number | undefined;
+
+ try {
+ if (fileType === "image") {
+ // Get image dimensions
+ const dimensions = await getImageDimensions(file);
+ width = dimensions.width;
+ height = dimensions.height;
+ } else if (fileType === "video") {
+ // Use basic thumbnail generation for now
+ const videoResult = await generateVideoThumbnail(file);
+ thumbnailUrl = videoResult.thumbnailUrl;
+ width = videoResult.width;
+ height = videoResult.height;
+ } else if (fileType === "audio") {
+ // For audio, we don't set width/height (they'll be undefined)
+ }
+
+ // Get duration for videos and audio (if not already set by FFmpeg)
+ if ((fileType === "video" || fileType === "audio") && !duration) {
+ duration = await getMediaDuration(file);
+ }
+
+ processedItems.push({
+ name: file.name,
+ type: fileType,
+ file,
+ url,
+ thumbnailUrl,
+ duration,
+ width,
+ height,
+ });
+
+ // Yield back to the event loop to keep the UI responsive
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress(percent);
+ }
+ } catch (error) {
+ console.error("Error processing file:", file.name, error);
+ toast.error(`Failed to process ${file.name}`);
+ URL.revokeObjectURL(url); // Clean up on error
+ }
+ }
+
+ return processedItems;
+}
diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts
new file mode 100644
index 00000000..f43a8798
--- /dev/null
+++ b/apps/web/src/lib/storage/indexeddb-adapter.ts
@@ -0,0 +1,89 @@
+import { StorageAdapter } from "./types";
+
+export class IndexedDBAdapter<T> implements StorageAdapter<T> {
+ private dbName: string;
+ private storeName: string;
+ private version: number;
+
+ constructor(dbName: string, storeName: string, version: number = 1) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.version = version;
+ }
+
+ private async getDB(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(this.storeName)) {
+ db.createObjectStore(this.storeName, { keyPath: "id" });
+ }
+ };
+ });
+ }
+
+ async get(key: string): Promise<T | null> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result || null);
+ });
+ }
+
+ async set(key: string, value: T): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.put({ id: key, ...value });
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async remove(key: string): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.delete(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async list(): Promise<string[]> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.getAllKeys();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result as string[]);
+ });
+ }
+
+ async clear(): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.clear();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+}
diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts
new file mode 100644
index 00000000..c01954e5
--- /dev/null
+++ b/apps/web/src/lib/storage/opfs-adapter.ts
@@ -0,0 +1,73 @@
+import { StorageAdapter } from "./types";
+
+export class OPFSAdapter implements StorageAdapter<File> {
+ private directoryName: string;
+
+ constructor(directoryName: string = "media") {
+ this.directoryName = directoryName;
+ }
+
+ private async getDirectory(): Promise<FileSystemDirectoryHandle> {
+ const opfsRoot = await navigator.storage.getDirectory();
+ return await opfsRoot.getDirectoryHandle(this.directoryName, {
+ create: true,
+ });
+ }
+
+ async get(key: string): Promise<File | null> {
+ try {
+ const directory = await this.getDirectory();
+ const fileHandle = await directory.getFileHandle(key);
+ return await fileHandle.getFile();
+ } catch (error) {
+ if ((error as Error).name === "NotFoundError") {
+ return null;
+ }
+ throw error;
+ }
+ }
+
+ async set(key: string, file: File): Promise<void> {
+ const directory = await this.getDirectory();
+ const fileHandle = await directory.getFileHandle(key, { create: true });
+ const writable = await fileHandle.createWritable();
+
+ await writable.write(file);
+ await writable.close();
+ }
+
+ async remove(key: string): Promise<void> {
+ try {
+ const directory = await this.getDirectory();
+ await directory.removeEntry(key);
+ } catch (error) {
+ if ((error as Error).name !== "NotFoundError") {
+ throw error;
+ }
+ }
+ }
+
+ async list(): Promise<string[]> {
+ const directory = await this.getDirectory();
+ const keys: string[] = [];
+
+ for await (const name of directory.keys()) {
+ keys.push(name);
+ }
+
+ return keys;
+ }
+
+ async clear(): Promise<void> {
+ const directory = await this.getDirectory();
+
+ for await (const name of directory.keys()) {
+ await directory.removeEntry(name);
+ }
+ }
+
+ // Helper method to check OPFS support
+ static isSupported(): boolean {
+ return "storage" in navigator && "getDirectory" in navigator.storage;
+ }
+}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
new file mode 100644
index 00000000..65b3c134
--- /dev/null
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -0,0 +1,194 @@
+import { TProject } from "@/types/project";
+import { MediaItem } from "@/stores/media-store";
+import { IndexedDBAdapter } from "./indexeddb-adapter";
+import { OPFSAdapter } from "./opfs-adapter";
+import { MediaFileData, StorageConfig, SerializedProject } from "./types";
+
+class StorageService {
+ private projectsAdapter: IndexedDBAdapter<SerializedProject>;
+ private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
+ private mediaFilesAdapter: OPFSAdapter;
+ private config: StorageConfig;
+
+ constructor() {
+ this.config = {
+ projectsDb: "video-editor-projects",
+ mediaDb: "video-editor-media",
+ version: 1,
+ };
+
+ this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
+ this.config.projectsDb,
+ "projects",
+ this.config.version
+ );
+
+ this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
+ this.config.mediaDb,
+ "media-metadata",
+ this.config.version
+ );
+
+ this.mediaFilesAdapter = new OPFSAdapter("media-files");
+ }
+
+ // Project operations
+ async saveProject(project: TProject): Promise<void> {
+ // Convert TProject to serializable format
+ const serializedProject: SerializedProject = {
+ id: project.id,
+ name: project.name,
+ thumbnail: project.thumbnail,
+ createdAt: project.createdAt.toISOString(),
+ updatedAt: project.updatedAt.toISOString(),
+ };
+
+ await this.projectsAdapter.set(project.id, serializedProject);
+ }
+
+ async loadProject(id: string): Promise<TProject | null> {
+ const serializedProject = await this.projectsAdapter.get(id);
+
+ if (!serializedProject) return null;
+
+ // Convert back to TProject format
+ return {
+ id: serializedProject.id,
+ name: serializedProject.name,
+ thumbnail: serializedProject.thumbnail,
+ createdAt: new Date(serializedProject.createdAt),
+ updatedAt: new Date(serializedProject.updatedAt),
+ };
+ }
+
+ async loadAllProjects(): Promise<TProject[]> {
+ const projectIds = await this.projectsAdapter.list();
+ const projects: TProject[] = [];
+
+ for (const id of projectIds) {
+ const project = await this.loadProject(id);
+ if (project) {
+ projects.push(project);
+ }
+ }
+
+ // Sort by last updated (most recent first)
+ return projects.sort(
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
+ );
+ }
+
+ async deleteProject(id: string): Promise<void> {
+ await this.projectsAdapter.remove(id);
+ }
+
+ // Media operations
+ async saveMediaItem(mediaItem: MediaItem): Promise<void> {
+ // Save file to OPFS
+ await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
+
+ // Save metadata to IndexedDB
+ const metadata: MediaFileData = {
+ id: mediaItem.id,
+ name: mediaItem.name,
+ type: mediaItem.type,
+ size: mediaItem.file.size,
+ lastModified: mediaItem.file.lastModified,
+ width: mediaItem.width,
+ height: mediaItem.height,
+ duration: mediaItem.duration,
+ };
+
+ await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
+ }
+
+ async loadMediaItem(id: string): Promise<MediaItem | null> {
+ const [file, metadata] = await Promise.all([
+ this.mediaFilesAdapter.get(id),
+ this.mediaMetadataAdapter.get(id),
+ ]);
+
+ if (!file || !metadata) return null;
+
+ // Create new object URL for the file
+ const url = URL.createObjectURL(file);
+
+ return {
+ id: metadata.id,
+ name: metadata.name,
+ type: metadata.type,
+ file,
+ url,
+ width: metadata.width,
+ height: metadata.height,
+ duration: metadata.duration,
+ // thumbnailUrl would need to be regenerated or cached separately
+ };
+ }
+
+ async loadAllMediaItems(): Promise<MediaItem[]> {
+ const mediaIds = await this.mediaMetadataAdapter.list();
+ const mediaItems: MediaItem[] = [];
+
+ for (const id of mediaIds) {
+ const item = await this.loadMediaItem(id);
+ if (item) {
+ mediaItems.push(item);
+ }
+ }
+
+ return mediaItems;
+ }
+
+ async deleteMediaItem(id: string): Promise<void> {
+ await Promise.all([
+ this.mediaFilesAdapter.remove(id),
+ this.mediaMetadataAdapter.remove(id),
+ ]);
+ }
+
+ // Utility methods
+ async clearAllData(): Promise<void> {
+ await Promise.all([
+ this.projectsAdapter.clear(),
+ this.mediaMetadataAdapter.clear(),
+ this.mediaFilesAdapter.clear(),
+ ]);
+ }
+
+ async getStorageInfo(): Promise<{
+ projects: number;
+ mediaItems: number;
+ isOPFSSupported: boolean;
+ isIndexedDBSupported: boolean;
+ }> {
+ const [projectIds, mediaIds] = await Promise.all([
+ this.projectsAdapter.list(),
+ this.mediaMetadataAdapter.list(),
+ ]);
+
+ return {
+ projects: projectIds.length,
+ mediaItems: mediaIds.length,
+ isOPFSSupported: this.isOPFSSupported(),
+ isIndexedDBSupported: this.isIndexedDBSupported(),
+ };
+ }
+
+ // Check browser support
+ isOPFSSupported(): boolean {
+ return OPFSAdapter.isSupported();
+ }
+
+ isIndexedDBSupported(): boolean {
+ return "indexedDB" in window;
+ }
+
+ isFullySupported(): boolean {
+ return this.isIndexedDBSupported() && this.isOPFSSupported();
+ }
+}
+
+// Export singleton instance
+export const storageService = new StorageService();
+export { StorageService };
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
new file mode 100644
index 00000000..7ad18e19
--- /dev/null
+++ b/apps/web/src/lib/storage/types.ts
@@ -0,0 +1,42 @@
+import { TProject } from "@/types/project";
+
+export interface StorageAdapter<T> {
+ get(key: string): Promise<T | null>;
+ set(key: string, value: T): Promise<void>;
+ remove(key: string): Promise<void>;
+ list(): Promise<string[]>;
+ clear(): Promise<void>;
+}
+
+export interface MediaFileData {
+ id: string;
+ name: string;
+ type: "image" | "video" | "audio";
+ size: number;
+ lastModified: number;
+ width?: number;
+ height?: number;
+ duration?: number;
+ // File will be stored separately in OPFS
+}
+
+export interface StorageConfig {
+ projectsDb: string;
+ mediaDb: string;
+ version: number;
+}
+
+// Helper type for serialization - converts Date objects to strings
+export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+};
+
+// Extend FileSystemDirectoryHandle with missing async iterator methods
+declare global {
+ interface FileSystemDirectoryHandle {
+ keys(): AsyncIterableIterator<string>;
+ values(): AsyncIterableIterator<FileSystemHandle>;
+ entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
+ }
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
new file mode 100644
index 00000000..e2db28c2
--- /dev/null
+++ b/apps/web/src/lib/time.ts
@@ -0,0 +1,21 @@
+// Time-related utility functions
+
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
+export const formatTimeCode = (
+ timeInSeconds: number,
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
+): string => {
+ const hours = Math.floor(timeInSeconds / 3600);
+ const minutes = Math.floor((timeInSeconds % 3600) / 60);
+ const seconds = Math.floor(timeInSeconds % 60);
+ const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+
+ switch (format) {
+ case "MM:SS":
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:CS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ }
+};
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index f2d3f590..d20c4b1d 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,15 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
- const session = getSessionCookie(request);
- if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("redirect", request.url);
- return NextResponse.redirect(loginUrl);
+ if (path === "/editor" && process.env.NODE_ENV === "production") {
+ const homeUrl = new URL("/", request.url);
+ homeUrl.searchParams.set("redirect", request.url);
+ return NextResponse.redirect(homeUrl);
}
return NextResponse.next();
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index e8dd8e21..5382c3e2 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,20 +1,71 @@
import { create } from "zustand";
+import { CanvasSize, CanvasPreset } from "@/types/editor";
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
+ // Canvas/Project settings
+ canvasSize: CanvasSize;
+ canvasPresets: CanvasPreset[];
+
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
+ setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
+const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
+ { name: "16:9", width: 1920, height: 1080 },
+ { name: "9:16", width: 1080, height: 1920 },
+ { name: "1:1", width: 1080, height: 1080 },
+ { name: "4:3", width: 1440, height: 1080 },
+];
+
+// Helper function to find the best matching canvas preset for an aspect ratio
+const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
+ // Calculate aspect ratio for each preset and find the closest match
+ let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
+ let smallestDifference = Math.abs(
+ aspectRatio - bestMatch.width / bestMatch.height
+ );
+
+ for (const preset of DEFAULT_CANVAS_PRESETS) {
+ const presetAspectRatio = preset.width / preset.height;
+ const difference = Math.abs(aspectRatio - presetAspectRatio);
+
+ if (difference < smallestDifference) {
+ smallestDifference = difference;
+ bestMatch = preset;
+ }
+ }
+
+ // If the difference is still significant (> 0.1), create a custom size
+ // based on the media aspect ratio with a reasonable resolution
+ const bestAspectRatio = bestMatch.width / bestMatch.height;
+ if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
+ // Create custom dimensions based on the aspect ratio
+ if (aspectRatio > 1) {
+ // Landscape - use 1920 width
+ return { width: 1920, height: Math.round(1920 / aspectRatio) };
+ } else {
+ // Portrait or square - use 1080 height
+ return { width: Math.round(1080 * aspectRatio), height: 1080 };
+ }
+ }
+
+ return { width: bestMatch.width, height: bestMatch.height };
+};
+
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
+ canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
@@ -32,4 +83,17 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
+
+ setCanvasSize: (size) => {
+ set({ canvasSize: size });
+ },
+
+ setCanvasSizeFromAspectRatio: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ console.log(
+ `Setting canvas size based on aspect ratio ${aspectRatio}:`,
+ newCanvasSize
+ );
+ set({ canvasSize: newCanvasSize });
+ },
}));
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index bdb4d52a..48932db5 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -1,170 +1,233 @@
-import { create } from "zustand";
-
-export interface MediaItem {
- id: string;
- name: string;
- type: "image" | "video" | "audio";
- file: File;
- url: string; // Object URL for preview
- thumbnailUrl?: string; // For video thumbnails
- duration?: number; // For video/audio duration
- aspectRatio: number; // width / height
-}
-
-interface MediaStore {
- mediaItems: MediaItem[];
-
- // Actions
- addMediaItem: (item: Omit<MediaItem, "id">) => void;
- removeMediaItem: (id: string) => void;
- clearAllMedia: () => void;
-}
-
-// Helper function to determine file type
-export const getFileType = (file: File): "image" | "video" | "audio" | null => {
- const { type } = file;
-
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
-
- return null;
-};
-
-// Helper function to get image aspect ratio
-export const getImageAspectRatio = (file: File): Promise<number> => {
- return new Promise((resolve, reject) => {
- const img = new Image();
-
- img.addEventListener("load", () => {
- const aspectRatio = img.naturalWidth / img.naturalHeight;
- resolve(aspectRatio);
- img.remove();
- });
-
- img.addEventListener("error", () => {
- reject(new Error("Could not load image"));
- img.remove();
- });
-
- img.src = URL.createObjectURL(file);
- });
-};
-
-// Helper function to generate video thumbnail and get aspect ratio
-export const generateVideoThumbnail = (
- file: File
-): Promise<{ thumbnailUrl: string; aspectRatio: number }> => {
- return new Promise((resolve, reject) => {
- const video = document.createElement("video");
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
-
- if (!ctx) {
- reject(new Error("Could not get canvas context"));
- return;
- }
-
- video.addEventListener("loadedmetadata", () => {
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
-
- // Seek to 1 second or 10% of duration, whichever is smaller
- video.currentTime = Math.min(1, video.duration * 0.1);
- });
-
- video.addEventListener("seeked", () => {
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
- const aspectRatio = video.videoWidth / video.videoHeight;
-
- resolve({ thumbnailUrl, aspectRatio });
-
- // Cleanup
- video.remove();
- canvas.remove();
- });
-
- video.addEventListener("error", () => {
- reject(new Error("Could not load video"));
- video.remove();
- canvas.remove();
- });
-
- video.src = URL.createObjectURL(file);
- video.load();
- });
-};
-
-// Helper function to get media duration
-export const getMediaDuration = (file: File): Promise<number> => {
- return new Promise((resolve, reject) => {
- const element = document.createElement(
- file.type.startsWith("video/") ? "video" : "audio"
- ) as HTMLVideoElement | HTMLAudioElement;
-
- element.addEventListener("loadedmetadata", () => {
- resolve(element.duration);
- element.remove();
- });
-
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- element.remove();
- });
-
- element.src = URL.createObjectURL(file);
- element.load();
- });
-};
-
-export const useMediaStore = create<MediaStore>((set, get) => ({
- mediaItems: [],
-
- addMediaItem: (item) => {
- const newItem: MediaItem = {
- ...item,
- id: crypto.randomUUID(),
- };
- set((state) => ({
- mediaItems: [...state.mediaItems, newItem],
- }));
- },
-
- removeMediaItem: (id) => {
- const state = get();
- const item = state.mediaItems.find((item) => item.id === id);
-
- // Cleanup object URLs to prevent memory leaks
- if (item) {
- URL.revokeObjectURL(item.url);
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- }
-
- set((state) => ({
- mediaItems: state.mediaItems.filter((item) => item.id !== id),
- }));
- },
-
- clearAllMedia: () => {
- const state = get();
-
- // Cleanup all object URLs
- state.mediaItems.forEach((item) => {
- URL.revokeObjectURL(item.url);
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- });
-
- set({ mediaItems: [] });
- },
-}));
+import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
+
+export interface MediaItem {
+ id: string;
+ name: string;
+ type: "image" | "video" | "audio";
+ file: File;
+ url: string; // Object URL for preview
+ thumbnailUrl?: string; // For video thumbnails
+ duration?: number; // For video/audio duration
+ width?: number; // For video/image width
+ height?: number; // For video/image height
+}
+
+interface MediaStore {
+ mediaItems: MediaItem[];
+ isLoading: boolean;
+
+ // Actions
+ addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>;
+ removeMediaItem: (id: string) => Promise<void>;
+ loadAllMedia: () => Promise<void>;
+ clearAllMedia: () => Promise<void>;
+}
+
+// Helper function to determine file type
+export const getFileType = (file: File): "image" | "video" | "audio" | null => {
+ const { type } = file;
+
+ if (type.startsWith("image/")) {
+ return "image";
+ }
+ if (type.startsWith("video/")) {
+ return "video";
+ }
+ if (type.startsWith("audio/")) {
+ return "audio";
+ }
+
+ return null;
+};
+
+// Helper function to get image dimensions
+export const getImageDimensions = (
+ file: File
+): Promise<{ width: number; height: number }> => {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+
+ img.addEventListener("load", () => {
+ const width = img.naturalWidth;
+ const height = img.naturalHeight;
+ resolve({ width, height });
+ img.remove();
+ });
+
+ img.addEventListener("error", () => {
+ reject(new Error("Could not load image"));
+ img.remove();
+ });
+
+ img.src = URL.createObjectURL(file);
+ });
+};
+
+// Helper function to generate video thumbnail and get dimensions
+export const generateVideoThumbnail = (
+ file: File
+): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
+ return new Promise((resolve, reject) => {
+ const video = document.createElement("video");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ if (!ctx) {
+ reject(new Error("Could not get canvas context"));
+ return;
+ }
+
+ video.addEventListener("loadedmetadata", () => {
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+
+ // Seek to 1 second or 10% of duration, whichever is smaller
+ video.currentTime = Math.min(1, video.duration * 0.1);
+ });
+
+ video.addEventListener("seeked", () => {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
+ const width = video.videoWidth;
+ const height = video.videoHeight;
+
+ resolve({ thumbnailUrl, width, height });
+
+ // Cleanup
+ video.remove();
+ canvas.remove();
+ });
+
+ video.addEventListener("error", () => {
+ reject(new Error("Could not load video"));
+ video.remove();
+ canvas.remove();
+ });
+
+ video.src = URL.createObjectURL(file);
+ video.load();
+ });
+};
+
+// Helper function to get media duration
+export const getMediaDuration = (file: File): Promise<number> => {
+ return new Promise((resolve, reject) => {
+ const element = document.createElement(
+ file.type.startsWith("video/") ? "video" : "audio"
+ ) as HTMLVideoElement | HTMLAudioElement;
+
+ element.addEventListener("loadedmetadata", () => {
+ resolve(element.duration);
+ element.remove();
+ });
+
+ element.addEventListener("error", () => {
+ reject(new Error("Could not load media"));
+ element.remove();
+ });
+
+ element.src = URL.createObjectURL(file);
+ element.load();
+ });
+};
+
+// Helper to get aspect ratio from MediaItem
+export const getMediaAspectRatio = (item: MediaItem): number => {
+ if (item.width && item.height) {
+ return item.width / item.height;
+ }
+ return 16 / 9; // Default aspect ratio
+};
+
+export const useMediaStore = create<MediaStore>((set, get) => ({
+ mediaItems: [],
+ isLoading: false,
+
+ addMediaItem: async (item) => {
+ const newItem: MediaItem = {
+ ...item,
+ id: crypto.randomUUID(),
+ };
+
+ // Add to local state immediately for UI responsiveness
+ set((state) => ({
+ mediaItems: [...state.mediaItems, newItem],
+ }));
+
+ // Save to persistent storage in background
+ try {
+ await storageService.saveMediaItem(newItem);
+ } catch (error) {
+ console.error("Failed to save media item:", error);
+ // Remove from local state if save failed
+ set((state) => ({
+ mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id),
+ }));
+ }
+ },
+
+ removeMediaItem: async (id) => {
+ const state = get();
+ const item = state.mediaItems.find((item) => item.id === id);
+
+ // Cleanup object URLs to prevent memory leaks
+ if (item) {
+ URL.revokeObjectURL(item.url);
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ }
+
+ // Remove from local state immediately
+ set((state) => ({
+ mediaItems: state.mediaItems.filter((item) => item.id !== id),
+ }));
+
+ // Remove from persistent storage
+ try {
+ await storageService.deleteMediaItem(id);
+ } catch (error) {
+ console.error("Failed to delete media item:", error);
+ // Could re-add to local state here if needed
+ }
+ },
+
+ loadAllMedia: async () => {
+ set({ isLoading: true });
+
+ try {
+ const mediaItems = await storageService.loadAllMediaItems();
+ set({ mediaItems });
+ } catch (error) {
+ console.error("Failed to load media items:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ clearAllMedia: async () => {
+ const state = get();
+
+ // Cleanup all object URLs
+ state.mediaItems.forEach((item) => {
+ URL.revokeObjectURL(item.url);
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ });
+
+ // Clear local state
+ set({ mediaItems: [] });
+
+ // Clear persistent storage
+ try {
+ const mediaIds = state.mediaItems.map((item) => item.id);
+ await Promise.all(
+ mediaIds.map((id) => storageService.deleteMediaItem(id))
+ );
+ } catch (error) {
+ console.error("Failed to clear media items from storage:", error);
+ }
+ },
+}));
diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts
index 7706417a..450b6a6e 100644
--- a/apps/web/src/stores/panel-store.ts
+++ b/apps/web/src/stores/panel-store.ts
@@ -1,6 +1,14 @@
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;
+
interface PanelState {
// Panel sizes as percentages
toolsPanel: number;
@@ -21,11 +29,7 @@ export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Default sizes - optimized for responsiveness
- toolsPanel: 25,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
+ ...DEFAULT_PANEL_SIZES,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 0d0812cd..4fff83c2 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -1,41 +1,200 @@
import { TProject } from "@/types/project";
import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
interface ProjectStore {
activeProject: TProject | null;
+ savedProjects: TProject[];
+ isLoading: boolean;
+ isInitialized: boolean;
// Actions
- createNewProject: (name: string) => void;
+ createNewProject: (name: string) => Promise<string>;
+ loadProject: (id: string) => Promise<void>;
+ saveCurrentProject: () => Promise<void>;
+ loadAllProjects: () => Promise<void>;
+ deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
- updateProjectName: (name: string) => void;
+ renameProject: (projectId: string, name: string) => Promise<void>;
+ duplicateProject: (projectId: string) => Promise<string>;
}
-export const useProjectStore = create<ProjectStore>((set) => ({
+export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
+ savedProjects: [],
+ isLoading: true,
+ isInitialized: false,
- createNewProject: (name: string) => {
+ createNewProject: async (name: string) => {
const newProject: TProject = {
id: crypto.randomUUID(),
name,
+ thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
};
+
set({ activeProject: newProject });
+
+ try {
+ await storageService.saveProject(newProject);
+ // Reload all projects to update the list
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ toast.error("Failed to save new project");
+ throw error;
+ }
+ },
+
+ loadProject: async (id: string) => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const project = await storageService.loadProject(id);
+ if (project) {
+ set({ activeProject: project });
+ }
+ } catch (error) {
+ console.error("Failed to load project:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ saveCurrentProject: async () => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ try {
+ await storageService.saveProject(activeProject);
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to save project:", error);
+ }
+ },
+
+ loadAllProjects: async () => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const projects = await storageService.loadAllProjects();
+ set({ savedProjects: projects });
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ set({ isLoading: false, isInitialized: true });
+ }
+ },
+
+ deleteProject: async (id: string) => {
+ try {
+ await storageService.deleteProject(id);
+ await get().loadAllProjects(); // Refresh the list
+
+ // If we deleted the active project, close it
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: null });
+ }
+ } catch (error) {
+ console.error("Failed to delete project:", error);
+ }
},
closeProject: () => {
set({ activeProject: null });
},
- updateProjectName: (name: string) => {
- set((state) => ({
- activeProject: state.activeProject
- ? {
- ...state.activeProject,
- name,
- updatedAt: new Date(),
- }
- : null,
- }));
+ renameProject: async (id: string, name: string) => {
+ const { savedProjects } = get();
+
+ // Find the project to rename
+ const projectToRename = savedProjects.find((p) => p.id === id);
+ if (!projectToRename) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ return;
+ }
+
+ const updatedProject = {
+ ...projectToRename,
+ name,
+ updatedAt: new Date(),
+ };
+
+ try {
+ // Save to storage
+ await storageService.saveProject(updatedProject);
+
+ await get().loadAllProjects();
+
+ // Update activeProject if it's the same project
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: updatedProject });
+ }
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ },
+
+ duplicateProject: async (projectId: string) => {
+ try {
+ const project = await storageService.loadProject(projectId);
+ if (!project) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ throw new Error("Project not found");
+ }
+
+ const { savedProjects } = get();
+
+ // Extract the base name (remove any existing numbering)
+ const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
+ const baseName = numberMatch ? numberMatch[2] : project.name;
+ const existingNumbers: number[] = [];
+
+ // Check for pattern "(number) baseName" in existing projects
+ savedProjects.forEach((p) => {
+ const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
+ if (match && match[2] === baseName) {
+ existingNumbers.push(parseInt(match[1], 10));
+ }
+ });
+
+ const nextNumber =
+ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
+
+ const newProject: TProject = {
+ id: crypto.randomUUID(),
+ name: `(${nextNumber}) ${baseName}`,
+ thumbnail: project.thumbnail,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ await storageService.saveProject(newProject);
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ console.error("Failed to duplicate project:", error);
+ toast.error("Failed to duplicate project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ throw error;
+ }
},
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 73628497..01f4fea6 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,5 +1,7 @@
import { create } from "zustand";
import type { TrackType } from "@/types/timeline";
+import { useEditorStore } from "./editor-store";
+import { useMediaStore, getMediaAspectRatio } from "./media-store";
// Helper function to manage clip naming with suffixes
const getClipNameWithSuffix = (
@@ -199,6 +201,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
addClipToTrack: (trackId, clipData) => {
get().pushHistory();
+
+ // Check if this is the first clip being added to the timeline
+ const currentState = get();
+ const totalClipsInTimeline = currentState.tracks.reduce(
+ (total, track) => total + track.clips.length,
+ 0
+ );
+ const isFirstClip = totalClipsInTimeline === 0;
+
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
@@ -207,6 +218,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
trimEnd: 0,
};
+ // If this is the first clip, automatically set the project canvas size
+ // to match the media's aspect ratio
+ if (isFirstClip && clipData.mediaId) {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === clipData.mediaId
+ );
+
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem)
+ );
+ }
+ }
+
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts
index bf1dae2f..dce7ac50 100644
--- a/apps/web/src/types/editor.ts
+++ b/apps/web/src/types/editor.ts
@@ -1 +1,12 @@
export type BackgroundType = "blur" | "mirror" | "color";
+
+export interface CanvasSize {
+ width: number;
+ height: number;
+}
+
+export interface CanvasPreset {
+ name: string;
+ width: number;
+ height: number;
+}
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index 74886b24..8b1eff3f 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -1,6 +1,7 @@
export interface TProject {
id: string;
name: string;
+ thumbnail: string;
createdAt: Date;
updatedAt: Date;
}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 7fafc187..54a433a7 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,29 +1,20 @@
-import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
-
-export type TrackType = "video" | "audio" | "effects";
-
-export interface TimelineClipProps {
- clip: TimelineClip;
- track: TimelineTrack;
- zoomLevel: number;
- isSelected: boolean;
- onContextMenu: (e: React.MouseEvent, clipId: string) => void;
- onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
- onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
-}
-
-export interface ResizeState {
- clipId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
-}
-
-export interface ContextMenuState {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
-}
+import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
+
+export type TrackType = "video" | "audio" | "effects";
+
+export interface TimelineClipProps {
+ clip: TimelineClip;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
+ onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
+}
+
+export interface ResizeState {
+ clipId: string;
+ side: "left" | "right";
+ startX: number;
+ initialTrimStart: number;
+ initialTrimEnd: number;
+}