feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs
This commit is contained in:
@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import "./editor.css";
|
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "../../components/ui/resizable";
|
} from "../../../components/ui/resizable";
|
||||||
import { MediaPanel } from "../../components/editor/media-panel";
|
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||||
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
||||||
import { Timeline } from "../../components/editor/timeline";
|
import { Timeline } from "../../../components/editor/timeline";
|
||||||
import { PreviewPanel } from "../../components/editor/preview-panel";
|
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||||
import { EditorHeader } from "@/components/editor-header";
|
import { EditorHeader } from "@/components/editor-header";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
@ -55,7 +54,10 @@ export default function Editor() {
|
|||||||
className="min-h-0"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
{/* Tools Panel */}
|
{/* Tools Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={toolsPanel}
|
defaultSize={toolsPanel}
|
@ -6,6 +6,7 @@ import "./globals.css";
|
|||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
import { DevelopmentDebug } from "../components/development-debug";
|
import { DevelopmentDebug } from "../components/development-debug";
|
||||||
|
import { StorageProvider } from "../components/storage-provider";
|
||||||
import { baseMetaData } from "./metadata";
|
import { baseMetaData } from "./metadata";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@ -25,7 +26,7 @@ export default function RootLayout({
|
|||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{children}
|
<StorageProvider>{children}</StorageProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<DevelopmentDebug />
|
<DevelopmentDebug />
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Video,
|
Video,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TProject } from "@/types/project";
|
import { TProject } from "@/types/project";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@ -20,54 +21,22 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
// Hard-coded project data
|
import { useRouter } from "next/navigation";
|
||||||
const mockProjects: TProject[] = [
|
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||||
{
|
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||||
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
||||||
@ -79,7 +48,7 @@ export default function ProjectsPage() {
|
|||||||
<span className="text-sm font-medium">Back</span>
|
<span className="text-sm font-medium">Back</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
<CreateButton />
|
<CreateButton onClick={handleCreateProject} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
||||||
@ -89,35 +58,24 @@ export default function ProjectsPage() {
|
|||||||
Your Projects
|
Your Projects
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{mockProjects.length}{" "}
|
{savedProjects.length}{" "}
|
||||||
{mockProjects.length === 1 ? "project" : "projects"}
|
{savedProjects.length === 1 ? "project" : "projects"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<CreateButton />
|
<CreateButton onClick={handleCreateProject} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mockProjects.length === 0 ? (
|
{isLoading || !isInitialized ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||||
<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>
|
||||||
|
) : 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">
|
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
{mockProjects.map((project, index) => (
|
{savedProjects.map((project) => (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectCard key={project.id} project={project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -129,12 +87,9 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
function ProjectCard({ project }: { project: TProject }) {
|
function ProjectCard({ project }: { project: TProject }) {
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const formatDuration = (seconds: number): string => {
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||||
const minutes = Math.floor(seconds / 60);
|
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
const formatDate = (date: Date): string => {
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", {
|
||||||
@ -144,85 +99,162 @@ function ProjectCard({ project }: { project: TProject }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Link href={`/editor/${project.id}`} className="block group">
|
<>
|
||||||
<Card className="overflow-hidden bg-background border-none p-0">
|
<Link href={`/editor/${project.id}`} className="block group">
|
||||||
<div
|
<Card className="overflow-hidden bg-background border-none p-0">
|
||||||
className={`relative aspect-square bg-muted transition-opacity ${
|
<div
|
||||||
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
className={`relative aspect-square bg-muted transition-opacity ${
|
||||||
}`}
|
isDropdownOpen
|
||||||
>
|
? "opacity-65"
|
||||||
{/* Thumbnail preview */}
|
: "opacity-100 group-hover:opacity-65"
|
||||||
<div className="absolute inset-0">
|
}`}
|
||||||
<Image
|
>
|
||||||
src={project.thumbnail}
|
{/* Thumbnail preview or placeholder */}
|
||||||
alt="Project thumbnail"
|
<div className="absolute inset-0">
|
||||||
fill
|
{project.thumbnail ? (
|
||||||
className="object-cover"
|
<Image
|
||||||
/>
|
src={project.thumbnail}
|
||||||
</div>
|
alt="Project thumbnail"
|
||||||
|
fill
|
||||||
{/* Duration badge */}
|
className="object-cover"
|
||||||
<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 className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||||
</div>
|
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||||
</Link>
|
<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() {
|
function CreateButton({ onClick }: { onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Button className="flex">
|
<Button className="flex" onClick={onClick}>
|
||||||
<Plus className="!size-4" />
|
<Plus className="!size-4" />
|
||||||
<span className="text-sm font-medium">New project</span>
|
<span className="text-sm font-medium">New project</span>
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
53
apps/web/src/components/delete-project-dialog.tsx
Normal file
53
apps/web/src/components/delete-project-dialog.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -32,7 +32,9 @@ export function MediaPanel() {
|
|||||||
setProgress(p)
|
setProgress(p)
|
||||||
);
|
);
|
||||||
// Add each processed media item to the store
|
// Add each processed media item to the store
|
||||||
processedItems.forEach((item) => addMediaItem(item));
|
for (const item of processedItems) {
|
||||||
|
await addMediaItem(item);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show error toast if processing fails
|
// Show error toast if processing fails
|
||||||
console.error("Error processing files:", error);
|
console.error("Error processing files:", error);
|
||||||
@ -56,11 +58,10 @@ export function MediaPanel() {
|
|||||||
e.target.value = ""; // Reset input
|
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
|
// Remove a media item from the store
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
|
||||||
// Remove tracks automatically when delete media
|
// Remove tracks automatically when delete media
|
||||||
const { tracks, removeTrack } = useTimelineStore.getState();
|
const { tracks, removeTrack } = useTimelineStore.getState();
|
||||||
tracks.forEach((track) => {
|
tracks.forEach((track) => {
|
||||||
@ -69,12 +70,14 @@ export function MediaPanel() {
|
|||||||
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
|
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
|
||||||
});
|
});
|
||||||
// Only remove track if it becomes empty and has no other clips
|
// 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) {
|
if (updatedTrack && updatedTrack.clips.length === 0) {
|
||||||
removeTrack(track.id);
|
removeTrack(track.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
removeMediaItem(id);
|
await removeMediaItem(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (duration: number) => {
|
const formatDuration = (duration: number) => {
|
||||||
|
@ -34,7 +34,6 @@ export function TimelineClip({
|
|||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
isSelected,
|
isSelected,
|
||||||
onContextMenu,
|
|
||||||
onClipMouseDown,
|
onClipMouseDown,
|
||||||
onClipClick,
|
onClipClick,
|
||||||
}: TimelineClipProps) {
|
}: TimelineClipProps) {
|
||||||
@ -299,7 +298,7 @@ export function TimelineClip({
|
|||||||
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
|
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
|
||||||
onClick={(e) => onClipClick && onClipClick(e, clip)}
|
onClick={(e) => onClipClick && onClipClick(e, clip)}
|
||||||
onMouseDown={handleClipMouseDown}
|
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">
|
<div className="absolute inset-1 flex items-center p-1">
|
||||||
{renderClipContent()}
|
{renderClipContent()}
|
||||||
|
73
apps/web/src/components/rename-project-dialog.tsx
Normal file
73
apps/web/src/components/rename-project-dialog.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
81
apps/web/src/components/storage-provider.tsx
Normal file
81
apps/web/src/components/storage-provider.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
89
apps/web/src/lib/storage/indexeddb-adapter.ts
Normal file
89
apps/web/src/lib/storage/indexeddb-adapter.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
73
apps/web/src/lib/storage/opfs-adapter.ts
Normal file
73
apps/web/src/lib/storage/opfs-adapter.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
192
apps/web/src/lib/storage/storage-service.ts
Normal file
192
apps/web/src/lib/storage/storage-service.ts
Normal file
@ -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 };
|
41
apps/web/src/lib/storage/types.ts
Normal file
41
apps/web/src/lib/storage/types.ts
Normal file
@ -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]>;
|
||||||
|
}
|
||||||
|
}
|
@ -1,15 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { getSessionCookie } from "better-auth/cookies";
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname;
|
const path = request.nextUrl.pathname;
|
||||||
const session = getSessionCookie(request);
|
|
||||||
|
|
||||||
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
|
if (path === "/editor" && process.env.NODE_ENV === "production") {
|
||||||
const loginUrl = new URL("/login", request.url);
|
const homeUrl = new URL("/", request.url);
|
||||||
loginUrl.searchParams.set("redirect", request.url);
|
homeUrl.searchParams.set("redirect", request.url);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(homeUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { storageService } from "@/lib/storage/storage-service";
|
||||||
|
|
||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,11 +14,13 @@ export interface MediaItem {
|
|||||||
|
|
||||||
interface MediaStore {
|
interface MediaStore {
|
||||||
mediaItems: MediaItem[];
|
mediaItems: MediaItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
addMediaItem: (item: Omit<MediaItem, "id">) => void;
|
addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>;
|
||||||
removeMediaItem: (id: string) => void;
|
removeMediaItem: (id: string) => Promise<void>;
|
||||||
clearAllMedia: () => void;
|
loadAllMedia: () => Promise<void>;
|
||||||
|
clearAllMedia: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to determine file type
|
// Helper function to determine file type
|
||||||
@ -126,18 +129,32 @@ export const getMediaDuration = (file: File): Promise<number> => {
|
|||||||
|
|
||||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||||
mediaItems: [],
|
mediaItems: [],
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
addMediaItem: (item) => {
|
addMediaItem: async (item) => {
|
||||||
const newItem: MediaItem = {
|
const newItem: MediaItem = {
|
||||||
...item,
|
...item,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add to local state immediately for UI responsiveness
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
mediaItems: [...state.mediaItems, newItem],
|
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 state = get();
|
||||||
const item = state.mediaItems.find((item) => item.id === id);
|
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) => ({
|
set((state) => ({
|
||||||
mediaItems: state.mediaItems.filter((item) => item.id !== id),
|
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();
|
const state = get();
|
||||||
|
|
||||||
// Cleanup all object URLs
|
// Cleanup all object URLs
|
||||||
@ -165,6 +204,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear local state
|
||||||
set({ mediaItems: [] });
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -1,19 +1,32 @@
|
|||||||
import { TProject } from "@/types/project";
|
import { TProject } from "@/types/project";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { storageService } from "@/lib/storage/storage-service";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ProjectStore {
|
interface ProjectStore {
|
||||||
activeProject: TProject | null;
|
activeProject: TProject | null;
|
||||||
|
savedProjects: TProject[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
|
||||||
// Actions
|
// 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;
|
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,
|
activeProject: null,
|
||||||
|
savedProjects: [],
|
||||||
|
isLoading: true,
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
createNewProject: (name: string) => {
|
createNewProject: async (name: string) => {
|
||||||
const newProject: TProject = {
|
const newProject: TProject = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name,
|
name,
|
||||||
@ -21,22 +34,167 @@ export const useProjectStore = create<ProjectStore>((set) => ({
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
set({ activeProject: newProject });
|
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: () => {
|
closeProject: () => {
|
||||||
set({ activeProject: null });
|
set({ activeProject: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProjectName: (name: string) => {
|
renameProject: async (id: string, name: string) => {
|
||||||
set((state) => ({
|
const { savedProjects } = get();
|
||||||
activeProject: state.activeProject
|
|
||||||
? {
|
// Find the project to rename
|
||||||
...state.activeProject,
|
const projectToRename = savedProjects.find((p) => p.id === id);
|
||||||
name,
|
if (!projectToRename) {
|
||||||
updatedAt: new Date(),
|
toast.error("Project not found", {
|
||||||
}
|
description: "Please try again",
|
||||||
: null,
|
});
|
||||||
}));
|
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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
Reference in New Issue
Block a user