summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-06-30 19:58:36 +0200
committerMaze Winther <mazewinther@gmail.com>2025-06-30 19:58:36 +0200
commit09373eb4a3f792d96d97d81f4fb86539d76e5702 (patch)
treeadd2d5eee3b5922e0291f905ab77eb48f244596a /apps/web
parentcd30c205b4019fb38371ced956abe55b47054433 (diff)
downloadopencut-09373eb4a3f792d96d97d81f4fb86539d76e5702.tar.gz
feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx (renamed from apps/web/src/app/editor/page.tsx)14
-rw-r--r--apps/web/src/app/layout.tsx3
-rw-r--r--apps/web/src/app/projects/page.tsx488
-rw-r--r--apps/web/src/components/delete-project-dialog.tsx53
-rw-r--r--apps/web/src/components/editor/media-panel.tsx15
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx3
-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/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.ts192
-rw-r--r--apps/web/src/lib/storage/types.ts41
-rw-r--r--apps/web/src/middleware.ts10
-rw-r--r--apps/web/src/stores/media-store.ts62
-rw-r--r--apps/web/src/stores/project-store.ts186
15 files changed, 1114 insertions, 269 deletions
diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index e0013937..3970d72a 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";
@@ -55,7 +54,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/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/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 82bae5d1..c983894d 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,228 +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,
-} from "lucide-react";
-import { TProject } from "@/types/project";
-import Image from "next/image";
-import {
- DropdownMenu,
- DropdownMenuItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
- DropdownMenuSeparator,
-} from "@/components/ui/dropdown-menu";
-
-// Hard-coded project data
-const mockProjects: TProject[] = [
- {
- id: "1",
- name: "Summer Vacation Highlights",
- createdAt: new Date("2024-12-15"),
- updatedAt: new Date("2024-12-20"),
- thumbnail:
- "https://plus.unsplash.com/premium_photo-1750854354243-81f40af63a73?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- },
- {
- id: "2",
- name: "Product Demo Video",
- createdAt: new Date("2024-12-10"),
- updatedAt: new Date("2024-12-18"),
- thumbnail:
- "https://images.unsplash.com/photo-1750875936215-0c35c1742cd6?q=80&w=688&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- },
- {
- id: "3",
- name: "Wedding Ceremony Edit",
- createdAt: new Date("2024-12-05"),
- updatedAt: new Date("2024-12-16"),
- thumbnail:
- "https://images.unsplash.com/photo-1750967991618-7b64a3025381?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- },
- {
- id: "4",
- name: "Travel Vlog - Japan",
- createdAt: new Date("2024-11-28"),
- updatedAt: new Date("2024-12-14"),
- thumbnail:
- "https://images.unsplash.com/photo-1750639258774-9a714379a093?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- },
-];
-
-// Mock duration data (in seconds)
-const mockDurations: Record<string, number> = {
- "1": 245, // 4:05
- "2": 120, // 2:00
- "3": 1800, // 30:00
- "4": 780, // 13:00
- "5": 360, // 6:00
- "6": 180, // 3:00
-};
-
-export default function ProjectsPage() {
- 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 />
- </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">
- {mockProjects.length}{" "}
- {mockProjects.length === 1 ? "project" : "projects"}
- </p>
- </div>
- <div className="hidden md:block">
- <CreateButton />
- </div>
- </div>
-
- {mockProjects.length === 0 ? (
- <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>
- <Link href="/editor">
- <Button size="lg" className="gap-2">
- <Plus className="h-4 w-4" />
- Create Your First Project
- </Button>
- </Link>
- </div>
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {mockProjects.map((project, index) => (
- <ProjectCard key={project.id} project={project} />
- ))}
- </div>
- )}
- </main>
- </div>
- );
-}
-
-function ProjectCard({ project }: { project: TProject }) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
-
- 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 formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
-
- 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 */}
- <div className="absolute inset-0">
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- </div>
-
- {/* Duration badge */}
- <div className="absolute bottom-3 right-3 bg-background text-foreground text-xs px-2 py-1 rounded">
- {formatDuration(mockDurations[project.id] || 0)}
- </div>
- </div>
-
- <CardContent className="px-0 pt-5">
- <div className="flex items-start justify-between mb-2">
- <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
- {project.name}
- </h3>
- <DropdownMenu 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();
- console.log("close");
- }}
- >
- <DropdownMenuItem>Rename</DropdownMenuItem>
- <DropdownMenuItem>Duplicate</DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem variant="destructive">
- 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>
- );
-}
-
-function CreateButton() {
- return (
- <Button className="flex">
- <Plus className="!size-4" />
- <span className="text-sm font-medium">New project</span>
- </Button>
- );
-}
+"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/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index f717234d..dd12faff 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -32,7 +32,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 +58,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 +70,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) => {
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx
index b9d3b9a6..9b5f20f6 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) {
@@ -299,7 +298,7 @@ export function TimelineClip({
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
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()}
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/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..5582ae29
--- /dev/null
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -0,0 +1,192 @@
+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,
+ aspectRatio: mediaItem.aspectRatio,
+ 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,
+ aspectRatio: metadata.aspectRatio,
+ 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..d56deebd
--- /dev/null
+++ b/apps/web/src/lib/storage/types.ts
@@ -0,0 +1,41 @@
+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;
+ aspectRatio: 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]>;
+ }
+} \ No newline at end of file
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/media-store.ts b/apps/web/src/stores/media-store.ts
index bdb4d52a..bf465811 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -1,4 +1,5 @@
import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
export interface MediaItem {
id: string;
@@ -13,11 +14,13 @@ export interface MediaItem {
interface MediaStore {
mediaItems: MediaItem[];
+ isLoading: boolean;
// Actions
- addMediaItem: (item: Omit<MediaItem, "id">) => void;
- removeMediaItem: (id: string) => void;
- clearAllMedia: () => void;
+ addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>;
+ removeMediaItem: (id: string) => Promise<void>;
+ loadAllMedia: () => Promise<void>;
+ clearAllMedia: () => Promise<void>;
}
// Helper function to determine file type
@@ -126,18 +129,32 @@ export const getMediaDuration = (file: File): Promise<number> => {
export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [],
+ isLoading: false,
- addMediaItem: (item) => {
+ 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: (id) => {
+ removeMediaItem: async (id) => {
const state = get();
const item = state.mediaItems.find((item) => item.id === id);
@@ -149,12 +166,34 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}
}
+ // 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
+ }
},
- clearAllMedia: () => {
+ 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
@@ -165,6 +204,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}
});
+ // 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/project-store.ts b/apps/web/src/stores/project-store.ts
index 43113fc1..4fff83c2 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -1,19 +1,32 @@
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,
@@ -21,22 +34,167 @@ export const useProjectStore = create<ProjectStore>((set) => ({
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;
+ }
},
}));