mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
yes
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
@@ -36,7 +37,7 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.5.3",
|
||||
"next": "^15.5.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1002 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10_2)">
|
||||
<path d="M512 149.969V362.032L362.032 512H149.969L0 362.032V149.969L149.969 0H362.032L512 149.969ZM128 128V384H384V128H128Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10_2">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,53 +0,0 @@
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BasePageProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
maxWidth?: "3xl" | "6xl" | "full";
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function BasePage({
|
||||
children,
|
||||
className = "",
|
||||
mainClassName = "",
|
||||
maxWidth = "3xl",
|
||||
title,
|
||||
description,
|
||||
}: BasePageProps) {
|
||||
const maxWidthClass = {
|
||||
"3xl": "max-w-3xl",
|
||||
"6xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<section className={cn("bg-background min-h-screen", className)}>
|
||||
<Header />
|
||||
<main
|
||||
className={cn(
|
||||
"container relative mx-auto flex flex-col gap-12 px-6 pb-24 pt-12 md:pt-24",
|
||||
maxWidthClass,
|
||||
mainClassName,
|
||||
)}
|
||||
>
|
||||
{title && description && (
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
import { Header } from "@/components/header";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { useFileUpload } from "@opencut/hooks/use-file-upload";
|
||||
import { useFilePaste } from "@opencut/hooks/use-file-paste";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
id: string;
|
||||
progress: number;
|
||||
status: "pending" | "processing" | "completed" | "error";
|
||||
batchId: string;
|
||||
}
|
||||
|
||||
export function BGRemoverClient() {
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isFinishedUploading, setIsFinishedUploading] = useState(false);
|
||||
|
||||
const handleFiles = ({ files: newFiles }: { files: File[] | FileList }) => {
|
||||
const newFileStatuses: FileStatus[] = Array.from(newFiles).map((file) => ({
|
||||
file,
|
||||
id: generateUUID(),
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
batchId: generateUUID(),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFileStatuses]);
|
||||
console.log(newFileStatuses);
|
||||
// setIsFinishedUploading(false);
|
||||
// setIsPopoverOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFiles((prev) => {
|
||||
if (
|
||||
!prev.some((f) => f.status === "pending" || f.status === "processing")
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return prev.map((f) => {
|
||||
if (f.status === "completed") return f;
|
||||
|
||||
if (f.status === "pending") {
|
||||
return { ...f, status: "processing", progress: 0 };
|
||||
}
|
||||
|
||||
if (f.status === "processing") {
|
||||
const newProgress = Math.min(
|
||||
f.progress + Math.random() * 15 + 5,
|
||||
100,
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
progress: newProgress,
|
||||
status: newProgress >= 100 ? "completed" : "processing",
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
});
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length > 0 && files.every((f) => f.status === "completed")) {
|
||||
setIsFinishedUploading(true);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const { isDragOver, openFilePicker, fileInputProps, dragProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*",
|
||||
multiple: false,
|
||||
onFilesSelected: (files) => handleFiles({ files }),
|
||||
});
|
||||
|
||||
useFilePaste({ onFilesPaste: (files) => handleFiles({ files }) });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-svh flex-col transition-opacity ${isDragOver ? "opacity-50" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<Header
|
||||
rightContent={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" className="h-10 px-5 text-[0.9rem] hover:bg-transparent">Auto</Button>
|
||||
<FilesPopover
|
||||
files={files}
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
isFinishedUploading={isFinishedUploading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={`mx-auto flex max-w-md flex-1 flex-col items-center justify-center gap-7 transition-opacity ${
|
||||
isDragOver ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<input {...fileInputProps} />
|
||||
<div className="relative flex size-28 items-center justify-center">
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="absolute size-28 blur-2xl"
|
||||
/>
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="absolute size-28 opacity-0 blur-lg"
|
||||
/>
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="relative z-10 size-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col items-center justify-center gap-5">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-3xl font-semibold">BG Remover</h1>
|
||||
<p className="text-muted-foreground text-center text-lg">
|
||||
Drag and drop, click to browse or paste from clipboard to upload
|
||||
an image.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={openFilePicker}>
|
||||
<UploadIcon />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FilesPopover = ({
|
||||
files,
|
||||
isPopoverOpen,
|
||||
setIsPopoverOpen,
|
||||
isFinishedUploading,
|
||||
}: {
|
||||
files: FileStatus[];
|
||||
isPopoverOpen: boolean;
|
||||
setIsPopoverOpen: (open: boolean) => void;
|
||||
isFinishedUploading: boolean;
|
||||
}) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const prevFileLengthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length > prevFileLengthRef.current) {
|
||||
const scrollToBottom = () => {
|
||||
scrollContainerRef.current?.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
if (scrollContainerRef.current) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
// popover just opened, wait for open animation to finish
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}
|
||||
prevFileLengthRef.current = files.length;
|
||||
}, [files.length]);
|
||||
|
||||
const isProcessing = files.some(
|
||||
(f) => f.status === "processing" || f.status === "pending",
|
||||
);
|
||||
|
||||
const inProgressFiles = files.filter(
|
||||
(f) => f.status === "processing" || f.status === "pending",
|
||||
);
|
||||
const overallProgress =
|
||||
inProgressFiles.length > 0
|
||||
? inProgressFiles.reduce((acc, f) => acc + f.progress, 0) /
|
||||
inProgressFiles.length
|
||||
: 0;
|
||||
|
||||
const hasCompletedFiles = files.some((f) => f.status === "completed");
|
||||
const canDownloadAll = hasCompletedFiles;
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative size-10 rounded-full transition-all duration-100 active:scale-[0.92]",
|
||||
isFinishedUploading &&
|
||||
"bg-primary hover:bg-primary/90 border-transparent",
|
||||
)}
|
||||
>
|
||||
{isProcessing && <ProgressIndicator progress={overallProgress} />}
|
||||
<DownloadIcon
|
||||
className={cn(isFinishedUploading && "text-primary-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="max-h-[23rem] w-[23rem] overflow-y-auto py-0"
|
||||
ref={scrollContainerRef}
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInteractive =
|
||||
target.closest("button") ||
|
||||
target.closest("a") ||
|
||||
target.closest("p") ||
|
||||
target.closest("h1") ||
|
||||
target.closest("h2") ||
|
||||
target.closest("h3") ||
|
||||
target.closest("h4") ||
|
||||
target.closest("h5") ||
|
||||
target.closest("h6") ||
|
||||
target.closest("input") ||
|
||||
target.closest("[role='button']") ||
|
||||
target.tagName === "IMG" ||
|
||||
target.tagName === "VIDEO";
|
||||
|
||||
if (isInteractive) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="bg-background sticky top-0 flex items-center justify-between pb-3 pt-4">
|
||||
<h3 className="text-base font-semibold">Downloads</h3>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{files.length === 0 && (
|
||||
<div className="text-muted-foreground pb-2 pt-4 text-center text-sm">
|
||||
No files uploaded yet
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{files.map((fileStatus) => (
|
||||
<FileItem key={fileStatus.id} fileStatus={fileStatus} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-background sticky bottom-0 flex flex-col gap-1 pb-3 pt-3.5">
|
||||
{files.length > 0 && (
|
||||
<Button
|
||||
disabled={!canDownloadAll}
|
||||
className={cn("w-full", files.length <= 1 && "hidden")}
|
||||
variant="outline"
|
||||
>
|
||||
Download all (
|
||||
{files.filter((f) => f.status === "completed").length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressIndicator = ({ progress }: { progress: number }) => (
|
||||
<svg
|
||||
className="absolute -inset-[1px] -rotate-90"
|
||||
style={{ width: "calc(100% + 2px)", height: "calc(100% + 2px)" }}
|
||||
viewBox="0 0 42 42"
|
||||
>
|
||||
<circle
|
||||
cx="21"
|
||||
cy="21"
|
||||
r="20.5"
|
||||
fill="none"
|
||||
className="stroke-primary transition-all duration-300 ease-in-out"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={128.81}
|
||||
strokeDashoffset={128.81 * (1 - progress / 100)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FileItem = ({ fileStatus }: { fileStatus: FileStatus }) => (
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={URL.createObjectURL(fileStatus.file)}
|
||||
alt={fileStatus.file.name}
|
||||
className="size-12 flex-shrink-0 rounded-sm object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div className="flex flex-col items-start justify-center gap-1">
|
||||
<p className="w-full truncate text-sm font-medium">
|
||||
{fileStatus.file.name.split(".").slice(0, -1).join(".")}
|
||||
</p>
|
||||
{fileStatus.status === "completed" ? (
|
||||
<Button variant="link" className="h-auto w-fit justify-start p-0">
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground whitespace-nowrap text-xs">
|
||||
{Math.round(fileStatus.progress)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import { Header } from "@/components/header";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { useFileUpload } from "@opencut/hooks/use-file-upload";
|
||||
import { useFilePaste } from "@opencut/hooks/use-file-paste";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
id: string;
|
||||
progress: number;
|
||||
status: "pending" | "processing" | "completed" | "error";
|
||||
batchId: string;
|
||||
}
|
||||
|
||||
export function BGRemoverClient() {
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
|
||||
const handleFiles = ({ files: newFiles }: { files: File[] | FileList }) => {
|
||||
const newFileStatuses: FileStatus[] = Array.from(newFiles).map((file) => ({
|
||||
file,
|
||||
id: generateUUID(),
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
batchId: generateUUID(),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFileStatuses]);
|
||||
console.log(newFileStatuses);
|
||||
};
|
||||
|
||||
const { isDragOver, openFilePicker, fileInputProps, dragProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*",
|
||||
multiple: false,
|
||||
onFilesSelected: (files) => handleFiles({ files }),
|
||||
});
|
||||
|
||||
useFilePaste({ onFilesPaste: (files) => handleFiles({ files }) });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-svh flex-col transition-opacity ${isDragOver ? "opacity-50" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<Header />
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{files.length === 0 ? (
|
||||
<motion.div
|
||||
key="upload"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.1, filter: "blur(10px)" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`mx-auto flex max-w-md flex-col items-center justify-center gap-7 transition-opacity ${
|
||||
isDragOver ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<input {...fileInputProps} />
|
||||
<div className="relative flex size-28 items-center justify-center">
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="absolute size-28 blur-2xl"
|
||||
/>
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="absolute size-28 opacity-0 blur-lg"
|
||||
/>
|
||||
<img
|
||||
src="/bg-remover.png"
|
||||
alt="BG Remover"
|
||||
className="relative z-10 size-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col items-center justify-center gap-5">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-3xl font-semibold">BG Remover</h1>
|
||||
<p className="text-muted-foreground text-center text-lg">
|
||||
Drag and drop, click to browse or paste from clipboard to
|
||||
upload an image.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={openFilePicker}>
|
||||
<UploadIcon />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="processing"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
bounce: 0.3,
|
||||
duration: 0.5,
|
||||
}}
|
||||
className="flex w-full max-w-4xl flex-col items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h2 className="text-2xl font-bold">Processing Images...</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{files.map((fileStatus) => (
|
||||
<div
|
||||
key={fileStatus.id}
|
||||
className="border-border bg-card relative overflow-hidden rounded-lg border p-2"
|
||||
>
|
||||
<img
|
||||
src={URL.createObjectURL(fileStatus.file)}
|
||||
alt={fileStatus.file.name}
|
||||
className="aspect-square w-full rounded-md object-cover"
|
||||
/>
|
||||
<div className="mt-2 text-center text-sm font-medium">
|
||||
{fileStatus.file.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFiles([])}
|
||||
className="mt-4"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
user uploads an image
|
||||
|
||||
new view shows with the image
|
||||
|
||||
they can "paint" on it (to create rough mask around subject to keep)
|
||||
|
||||
button to "remove background"
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BGRemoverClient } from "./client-page";
|
||||
|
||||
export default function BGRemoverPage() {
|
||||
return <BGRemoverClient />;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(205, 84%, 47%);
|
||||
--primary: hsl(214, 92%, 51%);
|
||||
--primary-foreground: hsl(0 0% 91%);
|
||||
--secondary: hsl(216, 13%, 92%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function RootLayout({
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${siteFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -9,8 +9,13 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Hello World</h1>
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h3 className="text-xl font-semibold">Background Remover</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Remove backgrounds from images with AI-powered precision
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { GithubIcon, MenuIcon } from "@opencut/ui/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { DEFAULT_LOGO_URL } from "@/constants/site-constants";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: "Contributors",
|
||||
href: "/contributors",
|
||||
export function Header({rightContent}: {rightContent?: React.ReactNode}) {
|
||||
const pathname = usePathname();
|
||||
const tools = {
|
||||
video: {
|
||||
label: "Video tools",
|
||||
items: [
|
||||
{ name: "Video Editor", href: "/video-editor" },
|
||||
{ name: "Video Compressor", href: "/video-compressor" },
|
||||
{ name: "Video Converter", href: "/video-converter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sponsors",
|
||||
href: "/sponsors",
|
||||
image: {
|
||||
label: "Image tools",
|
||||
items: [
|
||||
{ name: "BG Remover", href: "/bg-remover" },
|
||||
{ name: "Color Replacer", href: "/color-replacer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Blog",
|
||||
href: "/blog",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-background shadow-background/85 sticky top-0 z-10 shadow-[0_30px_35px_15px_rgba(0,0,0,1)]">
|
||||
@@ -42,88 +45,41 @@ export function Header() {
|
||||
height={32}
|
||||
/>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-4 md:flex">
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Button variant="text" className="p-0 text-sm">
|
||||
{link.label}
|
||||
</Button>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6">
|
||||
{Object.keys(tools).map((category) => (
|
||||
<HoverCard openDelay={500} closeDelay={300}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button variant="text" className="p-0 font-normal">
|
||||
{tools[category as keyof typeof tools].label}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-48 p-2">
|
||||
<div className="flex flex-col">
|
||||
{tools[category as keyof typeof tools].items.map((tool) => {
|
||||
const isActive = pathname === tool.href;
|
||||
return (
|
||||
<div key={tool.href} className="mb-1 last:mb-0">
|
||||
<Link
|
||||
href={tool.href}
|
||||
className={`group flex items-center justify-between rounded-sm px-3 py-2 text-sm transition-colors ${
|
||||
isActive ? "bg-primary/10" : "hover:bg-accent/75"
|
||||
}`}
|
||||
>
|
||||
{tool.name}
|
||||
{isActive && (
|
||||
<CheckIcon className="text-primary size-4" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 md:hidden">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="flex items-center justify-center p-0"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<MenuIcon size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<Link href={SOCIAL_LINKS.github}>
|
||||
<Button className="bg-background text-sm" variant="outline">
|
||||
<GithubIcon className="h-4 w-4" />
|
||||
31k+
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/projects">
|
||||
<Button className="text-sm">
|
||||
Projects
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background/20 pointer-events-none fixed inset-0 opacity-0 backdrop-blur-3xl",
|
||||
"transition-opacity duration-150",
|
||||
isMenuOpen && "pointer-events-auto opacity-100",
|
||||
)}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<div className="relative h-full">
|
||||
<nav className="flex flex-col gap-3 px-6 pt-[5rem]">
|
||||
{links.map((link, index) => (
|
||||
<motion.div
|
||||
key={link.href}
|
||||
initial={{ scale: 0.98, opacity: 0 }}
|
||||
animate={{
|
||||
scale: isMenuOpen ? 1 : 0.98,
|
||||
opacity: isMenuOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: isMenuOpen ? index * 0.1 : 0,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-semibold"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
<ThemeToggle
|
||||
className="absolute bottom-8 right-8 size-10"
|
||||
iconClassName="!size-[1.2rem]"
|
||||
onToggle={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{rightContent}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,6 @@ const buttonVariants = cva(
|
||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
"primary-gradient":
|
||||
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
|
||||
destructive:
|
||||
"bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
|
||||
outline:
|
||||
|
||||
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_20px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
strokeWidth={1.5}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
@@ -10,7 +10,7 @@ export const SITE_INFO = {
|
||||
favicon: "/favicon.ico",
|
||||
};
|
||||
|
||||
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
|
||||
export const DEFAULT_LOGO_URL = "/logo.svg";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
x: "https://x.com/OpenCutTools",
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { CanvasPreset, PlatformLayout } from "@/types/editor";
|
||||
import { DEFAULT_CANVAS_PRESETS } from "@/constants/editor-constants";
|
||||
|
||||
interface LayoutGuideSettings {
|
||||
platform: PlatformLayout | null;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
canvasPresets: CanvasPreset[];
|
||||
layoutGuide: LayoutGuideSettings;
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
|
||||
toggleLayoutGuide: (platform: PlatformLayout) => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
layoutGuide: {
|
||||
platform: null,
|
||||
},
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setLayoutGuide: (settings) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
...state.layoutGuide,
|
||||
...settings,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
toggleLayoutGuide: (platform) => {
|
||||
set((state) => ({
|
||||
layoutGuide: {
|
||||
platform: state.layoutGuide.platform === platform ? null : platform,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "editor-settings",
|
||||
partialize: (state) => ({
|
||||
layoutGuide: state.layoutGuide,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,270 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { ActionWithOptionalArgs } from "@/constants/action-constants";
|
||||
import { isAppleDevice, isTypableDOMElement } from "@/lib/utils";
|
||||
import { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||
|
||||
// Default keybindings configuration
|
||||
export const defaultKeybindings: KeybindingConfig = {
|
||||
space: "toggle-play",
|
||||
j: "seek-backward",
|
||||
k: "toggle-play",
|
||||
l: "seek-forward",
|
||||
left: "frame-step-backward",
|
||||
right: "frame-step-forward",
|
||||
"shift+left": "jump-backward",
|
||||
"shift+right": "jump-forward",
|
||||
home: "goto-start",
|
||||
enter: "goto-start",
|
||||
end: "goto-end",
|
||||
s: "split-element",
|
||||
n: "toggle-snapping",
|
||||
"ctrl+a": "select-all",
|
||||
"ctrl+d": "duplicate-selected",
|
||||
"ctrl+c": "copy-selected",
|
||||
"ctrl+v": "paste-selected",
|
||||
"ctrl+z": "undo",
|
||||
"ctrl+shift+z": "redo",
|
||||
"ctrl+y": "redo",
|
||||
delete: "delete-selected",
|
||||
backspace: "delete-selected",
|
||||
};
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: ShortcutKey;
|
||||
existingAction: ActionWithOptionalArgs;
|
||||
newAction: ActionWithOptionalArgs;
|
||||
}
|
||||
|
||||
interface KeybindingsState {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
keybindingsEnabled: boolean;
|
||||
isRecording: boolean;
|
||||
|
||||
// Actions
|
||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
enableKeybindings: () => void;
|
||||
disableKeybindings: () => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
|
||||
// Validation
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: ActionWithOptionalArgs,
|
||||
) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[];
|
||||
|
||||
// Utility
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
function isDOMElement(el: EventTarget | null): el is HTMLElement {
|
||||
return !!el && (el instanceof Element || el instanceof HTMLElement);
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
keybindingsEnabled: true,
|
||||
isRecording: false,
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
|
||||
enableKeybindings: () => {
|
||||
set({ keybindingsEnabled: true });
|
||||
},
|
||||
|
||||
disableKeybindings: () => {
|
||||
set({ keybindingsEnabled: false });
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
// Validate all keys and actions
|
||||
for (const [key, action] of Object.entries(config)) {
|
||||
// Validate the key format
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
},
|
||||
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: ActionWithOptionalArgs,
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
existingAction,
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
set({ isRecording });
|
||||
},
|
||||
|
||||
getKeybindingsForAction: (action: ActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action,
|
||||
) as ShortcutKey[];
|
||||
},
|
||||
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: 2,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Utility functions
|
||||
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||
const target = ev.target;
|
||||
|
||||
// We may or may not have a modifier key
|
||||
const modifierKey = getActiveModifier(ev);
|
||||
|
||||
// We will always have a non-modifier key
|
||||
const key = getPressedKey(ev);
|
||||
if (!key) return null;
|
||||
|
||||
// All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
|
||||
if (modifierKey) {
|
||||
// If the modifier is shift and the target is an input, we ignore
|
||||
if (
|
||||
modifierKey === "shift" &&
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement(target as HTMLElement)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
// no modifier key here then we do not do anything while on input
|
||||
if (isDOMElement(target) && isTypableDOMElement(target as HTMLElement))
|
||||
return null;
|
||||
|
||||
// single key while not input
|
||||
return `${key}` as ShortcutKey;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
// Sometimes the property code is not available on the KeyboardEvent object
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
return "space";
|
||||
|
||||
// Check arrow keys
|
||||
if (key.startsWith("arrow")) {
|
||||
return key.slice(5);
|
||||
}
|
||||
|
||||
// Check for special keys
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
|
||||
// Check letter keys
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") {
|
||||
return letter;
|
||||
}
|
||||
}
|
||||
|
||||
// Check number keys using physical position for AZERTY support
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") {
|
||||
return digit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other layouts
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
// Check if slash, period or enter
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
// If no other cases match, this is not a valid key
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
// active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
|
||||
// modiferKeys object's keys are sorted to match the above order
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { MediaType, MediaFile } from "@/types/media";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
|
||||
interface MediaStore {
|
||||
mediaFiles: MediaFile[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
addMediaFile: (
|
||||
projectId: string,
|
||||
file: Omit<MediaFile, "id">
|
||||
) => Promise<void>;
|
||||
removeMediaFile: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearAllMedia: () => void;
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
export const getFileType = (file: File): MediaType | null => {
|
||||
const { type } = file;
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get image dimensions
|
||||
export const getImageDimensions = (
|
||||
file: File
|
||||
): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
resolve({ width, height });
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.addEventListener("error", () => {
|
||||
reject(new Error("Could not load image"));
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to generate video thumbnail and get dimensions
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Seek to 1 second or 10% of duration, whichever is smaller
|
||||
video.currentTime = Math.min(1, video.duration * 0.1);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
|
||||
resolve({ thumbnailUrl, width, height });
|
||||
|
||||
// Cleanup
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
reject(new Error("Could not load video"));
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get media duration
|
||||
export const getMediaDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const element = document.createElement(
|
||||
file.type.startsWith("video/") ? "video" : "audio"
|
||||
) as HTMLVideoElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
resolve(element.duration);
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Could not load media"));
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.src = URL.createObjectURL(file);
|
||||
element.load();
|
||||
});
|
||||
};
|
||||
|
||||
export const getMediaAspectRatio = (item: MediaFile): number => {
|
||||
if (item.width && item.height) {
|
||||
return item.width / item.height;
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaFiles: [],
|
||||
isLoading: false,
|
||||
|
||||
addMediaFile: async (projectId, file) => {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
set((state) => ({
|
||||
mediaFiles: [...state.mediaFiles, newItem],
|
||||
}));
|
||||
|
||||
// Save to persistent storage in background
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
// Remove from local state if save failed
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
removeMediaFile: async (projectId: string, id: string) => {
|
||||
const state = get();
|
||||
const item = state.mediaFiles.find((media) => media.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Remove from local state immediately
|
||||
set((state) => ({
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
|
||||
}));
|
||||
|
||||
// 2) Cascade into the timeline: remove any elements using this media ID
|
||||
const timeline = useTimelineStore.getState();
|
||||
const { tracks, deleteSelected, setSelectedElements } = timeline;
|
||||
|
||||
// Find all elements that reference this media
|
||||
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
|
||||
for (const track of tracks) {
|
||||
for (const el of track.elements) {
|
||||
if (el.type === "media" && el.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: el.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are elements to remove, use unified delete function
|
||||
if (elementsToRemove.length > 0) {
|
||||
setSelectedElements(elementsToRemove);
|
||||
deleteSelected();
|
||||
}
|
||||
|
||||
// 3) Remove from persistent storage
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadProjectMedia: async (projectId) => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
|
||||
// Regenerate thumbnails for video items
|
||||
const updatedMediaItems = await Promise.all(
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const { thumbnailUrl, width, height } =
|
||||
await generateVideoThumbnail(item.file);
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
width: width || item.width,
|
||||
height: height || item.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
set({ mediaFiles: updatedMediaItems });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearProjectMedia: async (projectId) => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
|
||||
// Clear persistent storage
|
||||
try {
|
||||
const mediaIds = state.mediaFiles.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMedia: () => {
|
||||
const state = get();
|
||||
|
||||
videoCache.clearAll();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaFiles: [] });
|
||||
},
|
||||
}));
|
||||
@@ -1,225 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type PanelPreset =
|
||||
| "default"
|
||||
| "media"
|
||||
| "inspector"
|
||||
| "vertical-preview";
|
||||
|
||||
interface PanelSizes {
|
||||
toolsPanel: number;
|
||||
previewPanel: number;
|
||||
propertiesPanel: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
}
|
||||
|
||||
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = {
|
||||
default: {
|
||||
toolsPanel: 25,
|
||||
previewPanel: 50,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
},
|
||||
media: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 45,
|
||||
propertiesPanel: 25,
|
||||
mainContent: 100,
|
||||
timeline: 25,
|
||||
},
|
||||
inspector: {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 70,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
"vertical-preview": {
|
||||
toolsPanel: 30,
|
||||
previewPanel: 40,
|
||||
propertiesPanel: 30,
|
||||
mainContent: 75,
|
||||
timeline: 25,
|
||||
},
|
||||
};
|
||||
|
||||
interface PanelState extends PanelSizes {
|
||||
activePreset: PanelPreset;
|
||||
presetCustomSizes: Record<PanelPreset, Partial<PanelSizes>>;
|
||||
resetCounter: number;
|
||||
|
||||
mediaViewMode: "grid" | "list";
|
||||
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||
|
||||
setActivePreset: (preset: PanelPreset) => void;
|
||||
resetPreset: (preset: PanelPreset) => void;
|
||||
getCurrentPresetSizes: () => PanelSizes;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...PRESET_CONFIGS.default,
|
||||
activePreset: "default" as PanelPreset,
|
||||
presetCustomSizes: {
|
||||
default: {},
|
||||
media: {},
|
||||
inspector: {},
|
||||
"vertical-preview": {},
|
||||
},
|
||||
resetCounter: 0,
|
||||
|
||||
mediaViewMode: "grid" as const,
|
||||
|
||||
setToolsPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
toolsPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
toolsPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPreviewPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
previewPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
previewPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setPropertiesPanel: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
propertiesPanel: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
propertiesPanel: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMainContent: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
mainContent: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
mainContent: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setTimeline: (size) => {
|
||||
const { activePreset, presetCustomSizes } = get();
|
||||
set({
|
||||
timeline: size,
|
||||
presetCustomSizes: {
|
||||
...presetCustomSizes,
|
||||
[activePreset]: {
|
||||
...presetCustomSizes[activePreset],
|
||||
timeline: size,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
|
||||
setActivePreset: (preset) => {
|
||||
const {
|
||||
activePreset: currentPreset,
|
||||
presetCustomSizes,
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
|
||||
const updatedPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[currentPreset]: {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
const customSizes = updatedPresetCustomSizes[preset] || {};
|
||||
const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes;
|
||||
|
||||
set({
|
||||
activePreset: preset,
|
||||
presetCustomSizes: updatedPresetCustomSizes,
|
||||
...finalSizes,
|
||||
});
|
||||
},
|
||||
|
||||
resetPreset: (preset) => {
|
||||
const { presetCustomSizes, activePreset, resetCounter } = get();
|
||||
const defaultSizes = PRESET_CONFIGS[preset];
|
||||
|
||||
const newPresetCustomSizes = {
|
||||
...presetCustomSizes,
|
||||
[preset]: {},
|
||||
};
|
||||
|
||||
const updates: Partial<PanelState> = {
|
||||
presetCustomSizes: newPresetCustomSizes,
|
||||
resetCounter: resetCounter + 1,
|
||||
};
|
||||
|
||||
if (preset === activePreset) {
|
||||
Object.assign(updates, defaultSizes);
|
||||
}
|
||||
|
||||
set(updates);
|
||||
},
|
||||
|
||||
getCurrentPresetSizes: () => {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
} = get();
|
||||
return {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
};
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,198 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
interface TPlaybackState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
speed: number;
|
||||
muted: boolean;
|
||||
previousVolume?: number;
|
||||
}
|
||||
|
||||
interface TPlaybackControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggle: () => void;
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
interface TPlaybackStore extends TPlaybackState, TPlaybackControls {
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
}
|
||||
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: () => TPlaybackStore) => {
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
if (state.isPlaying && state.currentTime < state.duration) {
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
|
||||
// Get actual content duration from timeline store
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
|
||||
// Stop at actual content end, not timeline duration
|
||||
// It was either this or reducing default min timeline to 1 second
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (newTime >= effectiveDuration) {
|
||||
// When content completes, pause just before the end so we can see the last frame
|
||||
const projectFps = useProjectStore.getState().activeProject?.fps;
|
||||
if (!projectFps)
|
||||
console.error(
|
||||
"Project FPS is not set, assuming " + DEFAULT_FPS + "fps",
|
||||
);
|
||||
|
||||
const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS
|
||||
const stopTime = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
state.pause();
|
||||
state.setCurrentTime(stopTime);
|
||||
// Notify video elements to sync with end position
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
// Notify video elements to sync
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", { detail: { time: newTime } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
let lastUpdate = performance.now();
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (playbackTimer) {
|
||||
cancelAnimationFrame(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlaybackStore = create<TPlaybackStore>((set, get) => ({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
previousVolume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (effectiveDuration > 0) {
|
||||
const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
if (state.currentTime >= endThreshold) {
|
||||
get().seek(0);
|
||||
}
|
||||
}
|
||||
|
||||
set({ isPlaying: true });
|
||||
startTimer(get);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
set({ isPlaying: false });
|
||||
stopTimer();
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const { isPlaying } = get();
|
||||
if (isPlaying) {
|
||||
get().pause();
|
||||
} else {
|
||||
get().play();
|
||||
}
|
||||
},
|
||||
|
||||
seek: (time: number) => {
|
||||
const { duration } = get();
|
||||
const clampedTime = Math.max(0, Math.min(duration, time));
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
const event = new CustomEvent("playback-seek", {
|
||||
detail: { time: clampedTime },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) =>
|
||||
set((state) => ({
|
||||
volume: Math.max(0, Math.min(1, volume)),
|
||||
muted: volume === 0,
|
||||
previousVolume: volume > 0 ? volume : state.previousVolume,
|
||||
})),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent("playback-speed", {
|
||||
detail: { speed: newSpeed },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
|
||||
mute: () => {
|
||||
const { volume, previousVolume } = get();
|
||||
set({
|
||||
muted: true,
|
||||
previousVolume: volume > 0 ? volume : previousVolume,
|
||||
volume: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unmute: () => {
|
||||
const { previousVolume } = get();
|
||||
set({ muted: false, volume: previousVolume ?? 1 });
|
||||
},
|
||||
|
||||
toggleMute: () => {
|
||||
const { muted } = get();
|
||||
if (muted) {
|
||||
get().unmute();
|
||||
} else {
|
||||
get().mute();
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,465 +0,0 @@
|
||||
import { TProject, TScene } from "@/types/project";
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useSceneStore } from "./scene-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { TCanvasSize } from "@/types/editor";
|
||||
import { DEFAULT_BLUR_INTENSITY, DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export function createMainScene(): TScene {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name: "Main Scene",
|
||||
isMain: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const createDefaultProject = (name: string): TProject => {
|
||||
const mainScene: TScene = createMainScene();
|
||||
|
||||
return {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: DEFAULT_BLUR_INTENSITY,
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
};
|
||||
};
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
invalidProjectIds?: Set<string>;
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
loadProject: (id: string) => Promise<void>;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadAllProjects: () => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
closeProject: () => void;
|
||||
renameProject: (projectId: string, name: string) => Promise<void>;
|
||||
duplicateProject: (projectId: string) => Promise<string>;
|
||||
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
||||
updateBackgroundType: (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number },
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
updateCanvasSize: ({ size }: { size: TCanvasSize }) => Promise<void>;
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string,
|
||||
) => TProject[];
|
||||
isInvalidProjectId: (id: string) => boolean;
|
||||
markProjectIdAsInvalid: (id: string) => void;
|
||||
clearInvalidProjectIds: () => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
invalidProjectIds: new Set<string>(),
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject = createDefaultProject(name);
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
sceneStore.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: 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 });
|
||||
}
|
||||
|
||||
// Prevent flicker when switching projects - clear all stores
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
sceneStore.initializeScenes({
|
||||
scenes: project.scenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
// Get current scene directly from project data (don't rely on store state)
|
||||
currentScene =
|
||||
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
||||
project.scenes.find((s) => s.isMain) ||
|
||||
project.scenes[0];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline({
|
||||
projectId: id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error; // Re-throw so the editor page can handle it
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
const currentScene = sceneStore.currentScene;
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: activeProject }),
|
||||
timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
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 Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProjectTimeline({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If deleted active project, close it and clear data
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: null });
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const sceneStore = useSceneStore.getState();
|
||||
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
sceneStore.clearScenes();
|
||||
},
|
||||
|
||||
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 {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if 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({ id: 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 = {
|
||||
...project,
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: 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;
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectBackground: async (backgroundColor: string) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateBackgroundType: async (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number },
|
||||
) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity !== undefined && {
|
||||
blurIntensity: options.blurIntensity,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectFps: async (fps: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateCanvasSize: async ({ size }: { size: TCanvasSize }) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...activeProject,
|
||||
canvasSize: size,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
const filteredProjects = savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
||||
const [key, order] = sortOption.split("-");
|
||||
|
||||
if (key !== "createdAt" && key !== "name") {
|
||||
console.warn(`Invalid sort key: ${key}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (aValue === undefined || bValue === undefined) return 0;
|
||||
|
||||
if (order === "asc") {
|
||||
if (aValue < bValue) return -1;
|
||||
if (aValue > bValue) return 1;
|
||||
return 0;
|
||||
}
|
||||
if (aValue > bValue) return -1;
|
||||
if (aValue < bValue) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedProjects;
|
||||
},
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => {
|
||||
const invalidIds = get().invalidProjectIds || new Set();
|
||||
return invalidIds.has(id);
|
||||
},
|
||||
|
||||
markProjectIdAsInvalid: (id: string) => {
|
||||
set((state) => ({
|
||||
invalidProjectIds: new Set([
|
||||
...(state.invalidProjectIds || new Set()),
|
||||
id,
|
||||
]),
|
||||
}));
|
||||
},
|
||||
|
||||
clearInvalidProjectIds: () => {
|
||||
set({ invalidProjectIds: new Set() });
|
||||
},
|
||||
}));
|
||||
@@ -1,12 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { SceneNode } from "@/services/renderer/nodes/root-node";
|
||||
|
||||
interface RendererStore {
|
||||
renderTree: SceneNode | null;
|
||||
setRenderTree: (renderTree: SceneNode | null) => void;
|
||||
}
|
||||
|
||||
export const useRendererStore = create<RendererStore>((set) => ({
|
||||
renderTree: null,
|
||||
setRenderTree: (renderTree) => set({ renderTree }),
|
||||
}));
|
||||
@@ -1,419 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { TScene } from "@/types/project";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
getActiveScene,
|
||||
updateSceneInArray,
|
||||
getMainScene as getMainSceneUtil,
|
||||
ensureMainScene,
|
||||
createScene as createSceneUtil,
|
||||
canDeleteScene,
|
||||
getFallbackSceneAfterDelete,
|
||||
normalizeScenes,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
removeBookmarkFromArray,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
interface SceneStore {
|
||||
currentScene: TScene | null;
|
||||
scenes: TScene[];
|
||||
activeScene: TScene | null;
|
||||
createScene: ({
|
||||
name,
|
||||
isMain,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}) => Promise<string>;
|
||||
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
renameScene: ({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}) => Promise<void>;
|
||||
switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
|
||||
toggleBookmark: ({ time }: { time: number }) => Promise<void>;
|
||||
isBookmarked: ({ time }: { time: number }) => boolean;
|
||||
removeBookmark: ({ time }: { time: number }) => Promise<void>;
|
||||
loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}) => void;
|
||||
clearScenes: () => void;
|
||||
}
|
||||
|
||||
export const useSceneStore = create<SceneStore>((set, get) => {
|
||||
const updateProjectWithScenes = async ({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
refreshProjectList = false,
|
||||
}: {
|
||||
updatedScenes: TScene[];
|
||||
updatedSceneId?: string;
|
||||
refreshProjectList?: boolean;
|
||||
}) => {
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedScene = updatedSceneId
|
||||
? updatedScenes.find((s) => s.id === updatedSceneId)
|
||||
: get().currentScene;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
set({ scenes: updatedScenes, currentScene: updatedScene || null });
|
||||
|
||||
if (refreshProjectList) {
|
||||
await projectStore.loadAllProjects();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentScene: null,
|
||||
scenes: [],
|
||||
get activeScene(): TScene | null {
|
||||
const { scenes, currentScene } = get();
|
||||
return getActiveScene({
|
||||
scenes,
|
||||
currentSceneId: currentScene?.id || "",
|
||||
});
|
||||
},
|
||||
|
||||
createScene: async ({ name, isMain = false }) => {
|
||||
const { scenes } = get();
|
||||
|
||||
const newScene = createSceneUtil({ name, isMain });
|
||||
const updatedScenes = [...scenes, newScene];
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({ updatedScenes });
|
||||
return newScene.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to create scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes, currentScene } = get();
|
||||
const sceneToDelete = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
|
||||
if (!canDelete) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
|
||||
|
||||
const newCurrentScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: sceneId,
|
||||
currentSceneId: currentScene?.id || null,
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: newCurrentScene?.id,
|
||||
});
|
||||
|
||||
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (activeProject) {
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: newCurrentScene.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameScene: async ({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { scenes } = get();
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId,
|
||||
updates: { name, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename scene:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
switchToScene: async ({ sceneId }: { sceneId: string }) => {
|
||||
const { scenes } = get();
|
||||
const targetScene = scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
const { currentScene } = get();
|
||||
|
||||
if (activeProject && currentScene) {
|
||||
await timelineStore.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: currentScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProject) {
|
||||
await timelineStore.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
}
|
||||
|
||||
set({ currentScene: targetScene });
|
||||
},
|
||||
|
||||
toggleBookmark: async ({ time }: { time: number }) => {
|
||||
const { activeScene, scenes, currentScene } = get();
|
||||
if (!activeScene || !currentScene) return;
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
const updatedBookmarks = toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
toast.error("Failed to update bookmarks", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
isBookmarked: ({ time }: { time: number }) => {
|
||||
const { activeScene, currentScene } = get();
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeScene || !currentScene || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
return isBookmarkAtTime({ bookmarks, frameTime });
|
||||
},
|
||||
|
||||
removeBookmark: async ({ time }: { time: number }) => {
|
||||
const { activeScene, scenes, currentScene } = get();
|
||||
if (!activeScene || !currentScene) return;
|
||||
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
const updatedBookmarks = removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
if (updatedBookmarks.length === bookmarks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
toast.error("Failed to remove bookmark", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getMainScene: () => {
|
||||
const { scenes } = get();
|
||||
return getMainSceneUtil({ scenes });
|
||||
},
|
||||
|
||||
getCurrentScene: () => {
|
||||
return get().currentScene;
|
||||
},
|
||||
|
||||
loadProjectScenes: async ({ projectId }: { projectId: string }) => {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (project?.scenes) {
|
||||
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: normalizedScenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
|
||||
set({
|
||||
scenes: normalizedScenes,
|
||||
currentScene,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
set({ scenes: [], currentScene: null });
|
||||
}
|
||||
},
|
||||
|
||||
initializeScenes: ({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}) => {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const currentScene = currentSceneId
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
|
||||
|
||||
set({
|
||||
scenes: ensuredScenes,
|
||||
currentScene: currentScene || fallbackScene,
|
||||
});
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
const projectStore = useProjectStore.getState();
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
storageService
|
||||
.saveProject({ project: updatedProject })
|
||||
.then(() => {
|
||||
useProjectStore.setState({ activeProject: updatedProject });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to save project with background scene:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearScenes: () => {
|
||||
set({
|
||||
scenes: [],
|
||||
currentScene: null,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { usePlaybackStore } from "./playback-store";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
|
||||
// Filter state
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
|
||||
// Search state
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
|
||||
// Pagination state
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
// Timeline integration
|
||||
addSoundToTimeline: (sound: SoundEffect) => Promise<boolean>;
|
||||
|
||||
setTopSoundEffects: (sounds: SoundEffect[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setHasLoaded: (loaded: boolean) => void;
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchResults: (results: SoundEffect[]) => void;
|
||||
setSearching: (searching: boolean) => void;
|
||||
setSearchError: (error: string | null) => void;
|
||||
setLastSearchQuery: (query: string) => void;
|
||||
setScrollPosition: (position: number) => void;
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page: number) => void;
|
||||
setHasNextPage: (hasNext: boolean) => void;
|
||||
setTotalCount: (count: number) => void;
|
||||
setLoadingMore: (loading: boolean) => void;
|
||||
appendSearchResults: (results: SoundEffect[]) => void;
|
||||
appendTopSounds: (results: SoundEffect[]) => void;
|
||||
resetPagination: () => void;
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
|
||||
removeSavedSound: (soundId: number) => Promise<void>;
|
||||
isSoundSaved: (soundId: number) => boolean;
|
||||
toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
// Search state
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
|
||||
// Pagination state
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchResults: (results) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: (searching) => set({ isSearching: searching }),
|
||||
setSearchError: (error) => set({ searchError: error }),
|
||||
setLastSearchQuery: (query) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: (position) => set({ scrollPosition: position }),
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: (count) => set({ totalCount: count }),
|
||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||
appendSearchResults: (results) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
appendTopSounds: (results) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
saveSoundEffect: async (soundEffect: SoundEffect) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect({ soundEffect });
|
||||
|
||||
// Refresh saved sounds
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
removeSavedSound: async (soundId: number) => {
|
||||
try {
|
||||
await storageService.removeSavedSound({ soundId });
|
||||
|
||||
// Update local state immediately
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
isSoundSaved: (soundId: number) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
|
||||
toggleSavedSound: async (soundEffect: SoundEffect) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved(soundEffect.id)) {
|
||||
await removeSavedSound(soundEffect.id);
|
||||
} else {
|
||||
await saveSoundEffect(soundEffect);
|
||||
}
|
||||
},
|
||||
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async (sound) => {
|
||||
const activeProject = useProjectStore.getState().activeProject;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${sound.name}.mp3`, {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
|
||||
await useMediaStore.getState().addMediaFile(activeProject.id, {
|
||||
name: sound.name,
|
||||
type: "audio",
|
||||
file,
|
||||
duration: sound.duration,
|
||||
url: URL.createObjectURL(file),
|
||||
});
|
||||
|
||||
const mediaItem = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find((item) => item.file === file);
|
||||
if (!mediaItem) throw new Error("Failed to create media item");
|
||||
|
||||
const success = useTimelineStore
|
||||
.getState()
|
||||
.addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
|
||||
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
throw new Error("Failed to add to timeline - check for overlaps");
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,231 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
getCollections,
|
||||
getCollection,
|
||||
searchIcons,
|
||||
downloadSvgAsText,
|
||||
svgToFile,
|
||||
type IconSet,
|
||||
type CollectionInfo,
|
||||
type IconSearchResult,
|
||||
} from "@/lib/iconify-api";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
|
||||
export type StickerCategory = "all" | "general" | "brands" | "emoji";
|
||||
|
||||
interface StickersStore {
|
||||
searchQuery: string;
|
||||
selectedCategory: StickerCategory;
|
||||
selectedCollection: string | null;
|
||||
viewMode: "search" | "browse" | "collection";
|
||||
|
||||
collections: Record<string, IconSet>;
|
||||
currentCollection: CollectionInfo | null;
|
||||
searchResults: IconSearchResult | null;
|
||||
recentStickers: string[];
|
||||
isLoadingCollections: boolean;
|
||||
isLoadingCollection: boolean;
|
||||
isSearching: boolean;
|
||||
isDownloading: boolean;
|
||||
addingSticker: string | null;
|
||||
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSelectedCategory: (category: StickerCategory) => void;
|
||||
setSelectedCollection: (collection: string | null) => void;
|
||||
setViewMode: (mode: "search" | "browse" | "collection") => void;
|
||||
|
||||
loadCollections: () => Promise<void>;
|
||||
loadCollection: (prefix: string) => Promise<void>;
|
||||
searchStickers: (query: string) => Promise<void>;
|
||||
downloadSticker: (iconName: string) => Promise<File | null>;
|
||||
addStickerToTimeline: (iconName: string) => Promise<void>;
|
||||
|
||||
addToRecentStickers: (iconName: string) => void;
|
||||
clearRecentStickers: () => void;
|
||||
}
|
||||
|
||||
const MAX_RECENT_STICKERS = 50;
|
||||
|
||||
export const useStickersStore = create<StickersStore>((set, get) => ({
|
||||
searchQuery: "",
|
||||
selectedCategory: "all",
|
||||
selectedCollection: null,
|
||||
viewMode: "browse",
|
||||
|
||||
collections: {},
|
||||
currentCollection: null,
|
||||
searchResults: null,
|
||||
recentStickers: [],
|
||||
|
||||
isLoadingCollections: false,
|
||||
isLoadingCollection: false,
|
||||
isSearching: false,
|
||||
isDownloading: false,
|
||||
addingSticker: null,
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
setSelectedCategory: (category) =>
|
||||
set({
|
||||
selectedCategory: category,
|
||||
viewMode: "browse",
|
||||
selectedCollection: null,
|
||||
currentCollection: null,
|
||||
}),
|
||||
|
||||
setSelectedCollection: (collection) => {
|
||||
set({
|
||||
selectedCollection: collection,
|
||||
viewMode: collection ? "collection" : "browse",
|
||||
currentCollection: null,
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
get().loadCollection(collection);
|
||||
}
|
||||
},
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
|
||||
loadCollections: async () => {
|
||||
set({ isLoadingCollections: true });
|
||||
try {
|
||||
const collections = await getCollections();
|
||||
set({ collections });
|
||||
} catch (error) {
|
||||
console.error("Failed to load collections:", error);
|
||||
} finally {
|
||||
set({ isLoadingCollections: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadCollection: async (prefix: string) => {
|
||||
set({ isLoadingCollection: true });
|
||||
try {
|
||||
const collection = await getCollection(prefix);
|
||||
set({ currentCollection: collection });
|
||||
} catch (error) {
|
||||
console.error(`Failed to load collection ${prefix}:`, error);
|
||||
set({ currentCollection: null });
|
||||
} finally {
|
||||
set({ isLoadingCollection: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchStickers: async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
set({ searchResults: null, viewMode: "browse" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedCategory } = get();
|
||||
|
||||
set({ isSearching: true, viewMode: "search" });
|
||||
try {
|
||||
let category: string | undefined;
|
||||
|
||||
if (selectedCategory !== "all") {
|
||||
if (selectedCategory === "general") {
|
||||
category = "General";
|
||||
} else if (selectedCategory === "brands") {
|
||||
category = "Brands / Social";
|
||||
} else if (selectedCategory === "emoji") {
|
||||
category = "Emoji";
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchIcons(query, 100, undefined, category);
|
||||
set({ searchResults: results });
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
set({ searchResults: null });
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
downloadSticker: async (iconName: string) => {
|
||||
set({ isDownloading: true });
|
||||
try {
|
||||
const svgText = await downloadSvgAsText(iconName, {
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
const fileName = `${iconName.replace(":", "-")}.svg`;
|
||||
const file = svgToFile(svgText, fileName);
|
||||
|
||||
get().addToRecentStickers(iconName);
|
||||
|
||||
return file;
|
||||
} catch (error) {
|
||||
console.error(`Failed to download sticker ${iconName}:`, error);
|
||||
return null;
|
||||
} finally {
|
||||
set({ isDownloading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addStickerToTimeline: async (iconName: string) => {
|
||||
set({ addingSticker: iconName });
|
||||
try {
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const file = await get().downloadSticker(iconName);
|
||||
if (!file) {
|
||||
throw new Error("Failed to download sticker");
|
||||
}
|
||||
|
||||
const mediaItem: Omit<MediaFile, "id"> = {
|
||||
name: iconName.replace(":", "-"),
|
||||
type: "image",
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
width: 200,
|
||||
height: 200,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
const { addMediaFile } = useMediaStore.getState();
|
||||
await addMediaFile(activeProject.id, mediaItem);
|
||||
|
||||
const added = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find(
|
||||
(m) => m.url === mediaItem.url && m.name === mediaItem.name
|
||||
);
|
||||
if (!added) {
|
||||
throw new Error("Sticker not in media store");
|
||||
}
|
||||
|
||||
const { currentTime } = usePlaybackStore.getState();
|
||||
const { addElementAtTime } = useTimelineStore.getState();
|
||||
addElementAtTime(added, currentTime);
|
||||
} finally {
|
||||
set({ addingSticker: null });
|
||||
}
|
||||
},
|
||||
|
||||
addToRecentStickers: (iconName: string) => {
|
||||
set((state) => {
|
||||
const recent = [
|
||||
iconName,
|
||||
...state.recentStickers.filter((s) => s !== iconName),
|
||||
];
|
||||
return {
|
||||
recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearRecentStickers: () => set({ recentStickers: [] }),
|
||||
}));
|
||||
@@ -1,33 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type TextPropertiesTab = "transform" | "style";
|
||||
|
||||
export interface TextPropertiesTabMeta {
|
||||
value: TextPropertiesTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
|
||||
{ value: "transform", label: "Transform" },
|
||||
{ value: "style", label: "Style" },
|
||||
] as const;
|
||||
|
||||
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
|
||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
||||
}
|
||||
|
||||
interface TextPropertiesState {
|
||||
activeTab: TextPropertiesTab;
|
||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
||||
}
|
||||
|
||||
export const useTextPropertiesStore = create<TextPropertiesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "transform",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" }
|
||||
)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_buildManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"encryption.key":"qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=","encryption.expire_at":1765569405530}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_buildManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": [],
|
||||
"preview": {
|
||||
"previewModeId": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"previewModeSigningKey": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f",
|
||||
"previewModeEncryptionKey": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js","destination":"https://api.vercel.com/bot-protection/v1/challenge","regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3\\/a-4-a\\/c\\.js(?:\\/)?$","check":true},{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*","destination":"https://api.vercel.com/bot-protection/v1/proxy/:path*","regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?(?:\\/)?$","check":true}],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[{"source":"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*","headers":[{"key":"X-Frame-Options","value":"SAMEORIGIN"},{"key":"Content-Security-Policy","value":"frame-ancestors 'self'"}],"regex":"^\\/149e9513-01fa-4fb0-aad4-566afd725d1b\\/2d206a39-8ed7-437e-a3be-862e0f06eea3(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?(?:\\/)?$"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,43 @@
|
||||
(globalThis.TURBOPACK || (globalThis.TURBOPACK = [])).push(["chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"[externals]/node:buffer [external] (node:buffer, cjs)", ((__turbopack_context__, module, exports) => {
|
||||
|
||||
const mod = __turbopack_context__.x("node:buffer", () => require("node:buffer"));
|
||||
|
||||
module.exports = mod;
|
||||
}),
|
||||
"[externals]/node:async_hooks [external] (node:async_hooks, cjs)", ((__turbopack_context__, module, exports) => {
|
||||
|
||||
const mod = __turbopack_context__.x("node:async_hooks", () => require("node:async_hooks"));
|
||||
|
||||
module.exports = mod;
|
||||
}),
|
||||
"[project]/apps/web/src/middleware.ts [middleware-edge] (ecmascript)", ((__turbopack_context__) => {
|
||||
"use strict";
|
||||
|
||||
__turbopack_context__.s([
|
||||
"config",
|
||||
()=>config,
|
||||
"middleware",
|
||||
()=>middleware
|
||||
]);
|
||||
var __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$api$2f$server$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__$3c$locals$3e$__ = __turbopack_context__.i("[project]/node_modules/.bun/next@15.5.3+6dbf9a050bc9aadb/node_modules/next/dist/esm/api/server.js [middleware-edge] (ecmascript) <locals>");
|
||||
var __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$web$2f$exports$2f$index$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/node_modules/.bun/next@15.5.3+6dbf9a050bc9aadb/node_modules/next/dist/esm/server/web/exports/index.js [middleware-edge] (ecmascript)");
|
||||
;
|
||||
async function middleware() {
|
||||
return __TURBOPACK__imported__module__$5b$project$5d2f$node_modules$2f2e$bun$2f$next$40$15$2e$5$2e$3$2b$6dbf9a050bc9aadb$2f$node_modules$2f$next$2f$dist$2f$esm$2f$server$2f$web$2f$exports$2f$index$2e$js__$5b$middleware$2d$edge$5d$__$28$ecmascript$29$__["NextResponse"].next();
|
||||
}
|
||||
const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/ "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
]
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
//# sourceMappingURL=%5Broot-of-the-server%5D__8bd37c4c._.js.map
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sections": [
|
||||
{"offset": {"line": 16, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/apps/web/src/middleware.ts"],"sourcesContent":["import { NextResponse } from \"next/server\";\r\n\r\nexport async function middleware() {\r\n return NextResponse.next();\r\n}\r\n\r\nexport const config = {\r\n matcher: [\r\n /*\r\n * Match all request paths except for the ones starting with:\r\n * - api (API routes)\r\n * - _next/static (static files)\r\n * - _next/image (image optimization files)\r\n * - favicon.ico (favicon file)\r\n */\r\n \"/((?!api|_next/static|_next/image|favicon.ico).*)\",\r\n ],\r\n};\r\n"],"names":[],"mappings":";;;;;;AAAA;AAAA;;AAEO,eAAe;IACpB,OAAO,qQAAY,CAAC,IAAI;AAC1B;AAEO,MAAM,SAAS;IACpB,SAAS;QACP;;;;;;KAMC,GACD;KACD;AACH"}}]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
@@ -0,0 +1,16 @@
|
||||
globalThis.__BUILD_MANIFEST = {
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [],
|
||||
"ampFirstPages": []
|
||||
};
|
||||
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 3,
|
||||
"middleware": {
|
||||
"/": {
|
||||
"files": [
|
||||
"server/edge/chunks/_886b33c3._.js",
|
||||
"server/edge/chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"server/edge/chunks/turbopack-apps_web_edge-wrapper_53e34dd4.js"
|
||||
],
|
||||
"name": "middleware",
|
||||
"page": "/",
|
||||
"matchers": [
|
||||
{
|
||||
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/((?!api|_next\\/static|_next\\/image|favicon.ico).*))(\\\\.json)?[\\/#\\?]?$",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
],
|
||||
"wasm": [],
|
||||
"assets": [],
|
||||
"env": {
|
||||
"__NEXT_BUILD_ID": "development",
|
||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=",
|
||||
"__NEXT_PREVIEW_MODE_ID": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62",
|
||||
"__NEXT_PREVIEW_MODE_SIGNING_KEY": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sortedMiddleware": [
|
||||
"/"
|
||||
],
|
||||
"functions": {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"sorted_middleware": [],
|
||||
"middleware": {
|
||||
"/": {
|
||||
"files": [
|
||||
"server/edge/chunks/_886b33c3._.js",
|
||||
"server/edge/chunks/[root-of-the-server]__8bd37c4c._.js",
|
||||
"server/edge/chunks/turbopack-apps_web_edge-wrapper_53e34dd4.js"
|
||||
],
|
||||
"name": "middleware",
|
||||
"page": "/",
|
||||
"matchers": [
|
||||
{
|
||||
"regexp": "/:nextData(_next/data/[^/]{1,})?/((?!api|_next/static|_next/image|favicon.ico).*){(\\\\.json)}?",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
],
|
||||
"wasm": [],
|
||||
"assets": [],
|
||||
"env": {
|
||||
"__NEXT_BUILD_ID": "development",
|
||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=",
|
||||
"__NEXT_PREVIEW_MODE_ID": "69359808c9a0e2ef1d49645b631e2df3",
|
||||
"__NEXT_PREVIEW_MODE_ENCRYPTION_KEY": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62",
|
||||
"__NEXT_PREVIEW_MODE_SIGNING_KEY": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"instrumentation": null,
|
||||
"functions": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"app": {},
|
||||
"appUsingSizeAdjust": false,
|
||||
"pages": {},
|
||||
"pagesUsingSizeAdjust": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc=\"\n}"
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "qDHbHIqItmhsNnDDb1zJxgaLO9R4RpheEkUFqJlwCAc="
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
self.__BUILD_MANIFEST = {
|
||||
"__rewrites": {
|
||||
"afterFiles": [
|
||||
{
|
||||
"source": "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js"
|
||||
},
|
||||
{
|
||||
"source": "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/:path*"
|
||||
}
|
||||
],
|
||||
"beforeFiles": [],
|
||||
"fallback": []
|
||||
},
|
||||
"sortedPages": [
|
||||
"/_app",
|
||||
"/_error"
|
||||
]
|
||||
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"regexp": "^(?:\\/(_next\\/data\\/[^/]{1,}))?(?:\\/((?!api|_next\\/static|_next\\/image|favicon.ico).*))(\\\\.json)?[\\/#\\?]?$",
|
||||
"originalSource": "/((?!api|_next/static|_next/image|favicon.ico).*)"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
@@ -0,0 +1,2 @@
|
||||
[{"name":"hot-reloader","duration":51,"timestamp":35746272416,"id":3,"tags":{"version":"15.5.3"},"startTime":1764359805524,"traceId":"5f6e64cd6ea5810f"},{"name":"compile-path","duration":168336,"timestamp":35746356050,"id":4,"tags":{"trigger":"middleware"},"startTime":1764359805608,"traceId":"5f6e64cd6ea5810f"}]
|
||||
[{"name":"next-dev","duration":2410420,"timestamp":35745076240,"id":1,"tags":{},"startTime":1764359804328,"traceId":"5f6e64cd6ea5810f"}]
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = "/" | "/blog" | "/blog/[slug]" | "/contributors" | "/editor/[project_id]" | "/privacy" | "/projects" | "/roadmap" | "/sponsors" | "/terms"
|
||||
type AppRouteHandlerRoutes = "/api/auth/[...all]" | "/api/get-upload-url" | "/api/health" | "/api/sounds/search" | "/api/transcribe" | "/rss.xml"
|
||||
type PageRoutes = never
|
||||
type LayoutRoutes = "/" | "/editor/[project_id]"
|
||||
type RedirectRoutes = never
|
||||
type RewriteRoutes = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...path]]" | "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js"
|
||||
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes | AppRouteHandlerRoutes
|
||||
|
||||
|
||||
interface ParamMap {
|
||||
"/": {}
|
||||
"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...path]]": { "path"?: string[]; }
|
||||
"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js": {}
|
||||
"/api/auth/[...all]": { "all": string[]; }
|
||||
"/api/get-upload-url": {}
|
||||
"/api/health": {}
|
||||
"/api/sounds/search": {}
|
||||
"/api/transcribe": {}
|
||||
"/blog": {}
|
||||
"/blog/[slug]": { "slug": string; }
|
||||
"/contributors": {}
|
||||
"/editor/[project_id]": { "project_id": string; }
|
||||
"/privacy": {}
|
||||
"/projects": {}
|
||||
"/roadmap": {}
|
||||
"/rss.xml": {}
|
||||
"/sponsors": {}
|
||||
"/terms": {}
|
||||
}
|
||||
|
||||
|
||||
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||
|
||||
interface LayoutSlotMap {
|
||||
"/": never
|
||||
"/editor/[project_id]": never
|
||||
}
|
||||
|
||||
|
||||
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap, AppRouteHandlerRoutes }
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Props for Next.js App Router page components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||
* const { slug } = await props.params
|
||||
* return <div>Blog post: {slug}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface PageProps<AppRoute extends AppRoutes> {
|
||||
params: Promise<ParamMap[AppRoute]>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for Next.js App Router layout components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||
* return <div>{props.children}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||
params: Promise<ParamMap[LayoutRoute]>
|
||||
children: React.ReactNode
|
||||
} & {
|
||||
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for Next.js App Router route handlers
|
||||
* @example
|
||||
* ```tsx
|
||||
* export async function GET(request: NextRequest, context: RouteContext<'/api/users/[id]'>) {
|
||||
* const { id } = await context.params
|
||||
* return Response.json({ id })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface RouteContext<AppRouteHandlerRoute extends AppRouteHandlerRoutes> {
|
||||
params: Promise<ParamMap[AppRouteHandlerRoute]>
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
@@ -43,7 +44,7 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.5.3",
|
||||
"next": "15.5.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { MediaPanel } from "@/components/editor/media-panel";
|
||||
import { AssetsPanel } from "@/components/editor/assets-panel";
|
||||
import { PropertiesPanel } from "@/components/editor/properties-panel";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { useProjectInitialization } from "@/hooks/use-project-initialization";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
useProjectInitialization({ projectId });
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout({}: {}) {
|
||||
const {
|
||||
activePreset,
|
||||
resetCounter,
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
mainContent,
|
||||
@@ -30,268 +49,53 @@ export default function Editor() {
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
activePreset,
|
||||
resetCounter,
|
||||
} = usePanelStore();
|
||||
|
||||
const {
|
||||
activeProject,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
} = useProjectStore();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.project_id as string;
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
return activePreset === "media" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`media-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
usePlaybackControls();
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const initProject = async () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate initialization
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is already loaded
|
||||
if (activeProject?.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check global invalid tracking first (most important for preventing duplicates)
|
||||
if (isInvalidProjectId(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already handled this project ID locally
|
||||
if (handledProjectIds.current.has(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as initializing to prevent race conditions
|
||||
isInitializingRef.current = true;
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Project loaded successfully
|
||||
isInitializingRef.current = false;
|
||||
} catch (error) {
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// More specific error handling - only create new project for actual "not found" errors
|
||||
const isProjectNotFound =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("not found") ||
|
||||
error.message.includes("does not exist") ||
|
||||
error.message.includes("Project not found"));
|
||||
|
||||
if (isProjectNotFound) {
|
||||
// Mark this project ID as invalid globally BEFORE creating project
|
||||
markProjectIdAsInvalid(projectId);
|
||||
|
||||
try {
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
|
||||
// Check again if component was unmounted
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createError) {
|
||||
console.error("Failed to create new project:", createError);
|
||||
}
|
||||
} else {
|
||||
// For other errors (storage issues, corruption, etc.), don't create new project
|
||||
console.error(
|
||||
"Project loading failed with recoverable error:",
|
||||
error,
|
||||
);
|
||||
// Remove from handled set so user can retry
|
||||
handledProjectIds.current.delete(projectId);
|
||||
}
|
||||
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initProject();
|
||||
|
||||
// Cleanup function to cancel async operations
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
{activePreset === "media" ? (
|
||||
<ResizablePanel
|
||||
defaultSize={100 - toolsPanel}
|
||||
minSize={60}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
key={`media-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={100 - toolsPanel}
|
||||
minSize={60}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "inspector" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`inspector-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - propertiesPanel}
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPropertiesPanel(100 - size)}
|
||||
className="min-h-0 min-w-0"
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
@@ -301,74 +105,62 @@ export default function Editor() {
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-h-0 min-w-0"
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "vertical-preview" ? (
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "inspector" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`inspector-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - propertiesPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPropertiesPanel(100 - size)}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
key={`vertical-preview-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - previewPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPreviewPanel(100 - size)}
|
||||
className="min-h-0 min-w-0"
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
@@ -377,83 +169,178 @@ export default function Editor() {
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0"
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "vertical-preview" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`vertical-preview-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - previewPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPreviewPanel(100 - size)}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
key={`default-${activePreset}-${resetCounter}`}
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-3"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Preview Area */}
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Timeline */}
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<Timeline />
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<ResizablePanelGroup
|
||||
key={`default-${activePreset}-${resetCounter}`}
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,15 +53,16 @@ export default function ProjectsPage() {
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
|
||||
new Set()
|
||||
new Set(),
|
||||
);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
||||
new Set()
|
||||
new Set(),
|
||||
);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState("createdAt-desc");
|
||||
const { getProjectThumbnail } = useTimelineStore();
|
||||
const router = useRouter();
|
||||
|
||||
const getProjectThumbnail = useCallback(
|
||||
@@ -73,9 +74,7 @@ export default function ProjectsPage() {
|
||||
setLoadingThumbnails((prev) => new Set(prev).add(projectId));
|
||||
|
||||
try {
|
||||
const thumbnail = await useTimelineStore
|
||||
.getState()
|
||||
.getProjectThumbnail(projectId);
|
||||
const thumbnail = await getProjectThumbnail(projectId);
|
||||
setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
|
||||
return thumbnail;
|
||||
} finally {
|
||||
@@ -86,7 +85,7 @@ export default function ProjectsPage() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
@@ -120,7 +119,7 @@ export default function ProjectsPage() {
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
|
||||
);
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
@@ -136,11 +135,11 @@ export default function ProjectsPage() {
|
||||
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
||||
<div className="bg-background min-h-screen">
|
||||
<div className="flex h-16 w-full items-center justify-between px-6 pt-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
||||
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-5! shrink-0" />
|
||||
<span className="text-sm font-medium">Back</span>
|
||||
@@ -172,17 +171,17 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
||||
<main className="mx-auto max-w-6xl px-6 pb-6 pt-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">
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Your Projects
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{savedProjects.length}{" "}
|
||||
{savedProjects.length === 1 ? "project" : "projects"}
|
||||
{isSelectionMode && selectedProjects.size > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
<span className="text-primary ml-2">
|
||||
• {selectedProjects.size} selected
|
||||
</span>
|
||||
)}
|
||||
@@ -221,7 +220,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 max-w-72">
|
||||
<div className="max-w-72 flex-1">
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
@@ -237,7 +236,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="justify-center items-center w-9 h-9"
|
||||
className="h-9 w-9 items-center justify-center"
|
||||
>
|
||||
<ArrowDown01
|
||||
strokeWidth={1.5}
|
||||
@@ -253,7 +252,7 @@ export default function ProjectsPage() {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "createdAt-desc"
|
||||
: "createdAt-asc"
|
||||
: "createdAt-asc",
|
||||
);
|
||||
} else {
|
||||
setSortOption("createdAt-asc");
|
||||
@@ -270,7 +269,7 @@ export default function ProjectsPage() {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "name-desc"
|
||||
: "name-asc"
|
||||
: "name-asc",
|
||||
);
|
||||
} else {
|
||||
setSortOption("name-asc");
|
||||
@@ -305,32 +304,32 @@ export default function ProjectsPage() {
|
||||
handleSelectAll(!allSelected);
|
||||
}
|
||||
}}
|
||||
className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
|
||||
className="bg-muted/30 mb-6 flex w-full items-center gap-2 rounded-lg border p-4 hover:cursor-pointer"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox checked={someSelected ? "indeterminate" : allSelected} />
|
||||
<span className="text-sm font-medium">
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({selectedProjects.size} of {sortedProjects.length} selected)
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading || !isInitialized ? (
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}-${Date.now()}`}
|
||||
className="overflow-hidden bg-background border-none p-0"
|
||||
className="bg-background overflow-hidden border-none p-0"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full bg-muted/50" />
|
||||
<div className="px-0 pt-5 flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/50" />
|
||||
<Skeleton className="bg-muted/50 aspect-square w-full" />
|
||||
<div className="flex flex-col gap-1 px-0 pt-5">
|
||||
<Skeleton className="bg-muted/50 h-4 w-3/4" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-4 w-4 bg-muted/50" />
|
||||
<Skeleton className="h-4 w-24 bg-muted/50" />
|
||||
<Skeleton className="bg-muted/50 h-4 w-4" />
|
||||
<Skeleton className="bg-muted/50 h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,7 +343,7 @@ export default function ProjectsPage() {
|
||||
onClearSearch={() => setSearchQuery("")}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sortedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
@@ -442,25 +441,25 @@ function ProjectCard({
|
||||
|
||||
const cardContent = (
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
className={`bg-background overflow-hidden border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
className={`bg-muted relative aspect-square transition-opacity ${
|
||||
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-xs border flex items-center justify-center">
|
||||
<div className="absolute left-3 top-3 z-10">
|
||||
<div className="bg-background/80 backdrop-blur-xs flex h-5 w-5 items-center justify-center rounded-full border">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelect?.(project.id, checked as boolean)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,8 +467,8 @@ function ProjectCard({
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{isLoadingThumbnail ? (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 text-muted-foreground animate-spin" />
|
||||
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
) : dynamicThumbnail ? (
|
||||
<Image
|
||||
@@ -479,16 +478,16 @@ function ProjectCard({
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 shrink-0 text-muted-foreground" />
|
||||
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
|
||||
<Video className="text-muted-foreground h-12 w-12 shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<CardContent className="flex flex-col gap-1 px-0 pt-5">
|
||||
<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">
|
||||
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm font-medium leading-snug transition-colors">
|
||||
{project.name}
|
||||
</h3>
|
||||
{!isSelectionMode && (
|
||||
@@ -500,7 +499,7 @@ function ProjectCard({
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
@@ -554,7 +553,7 @@ function ProjectCard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
||||
<Calendar className="size-4!" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
@@ -570,12 +569,12 @@ function ProjectCard({
|
||||
type="button"
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
className="block group cursor-pointer w-full text-left"
|
||||
className="group block w-full cursor-pointer text-left"
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<Link href={`/editor/${project.id}`} className="block group">
|
||||
<Link href={`/editor/${project.id}`} className="group block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
)}
|
||||
@@ -606,10 +605,10 @@ function CreateButton({ onClick }: { onClick?: () => void }) {
|
||||
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 className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Video className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<h3 className="mb-2 text-lg font-medium">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.
|
||||
@@ -631,10 +630,10 @@ function NoResults({
|
||||
}) {
|
||||
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">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Search className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No results found</h3>
|
||||
<h3 className="mb-2 text-lg font-medium">No results found</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Your search for "{searchQuery}" did not return any results.
|
||||
</p>
|
||||
|
||||
+8
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { useAssetsPanelStore, Tab } from "../../../stores/assets-panel-store";
|
||||
import { TextView } from "./views/text";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
@@ -10,8 +10,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { Captions } from "./views/captions";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
export function AssetsPanel() {
|
||||
const { activeTab } = useAssetsPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
@@ -19,23 +19,23 @@ export function MediaPanel() {
|
||||
text: <TextView />,
|
||||
stickers: <StickersView />,
|
||||
effects: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
transitions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: <Captions />,
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Filters view coming soon...
|
||||
</div>
|
||||
),
|
||||
adjustment: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
<div className="text-muted-foreground p-4">
|
||||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
@@ -43,7 +43,7 @@ export function MediaPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-panel">
|
||||
<div className="bg-panel flex h-full">
|
||||
<TabBar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
+21
-11
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import {
|
||||
Tab,
|
||||
tabs,
|
||||
useAssetsPanelStore,
|
||||
} from "../../../stores/assets-panel-store";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,7 +14,7 @@ import {
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||
@@ -41,20 +45,20 @@ export function TabBar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex relative">
|
||||
<div className="relative flex">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
|
||||
className="scrollbar-hidden relative flex h-full w-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
|
||||
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
@@ -69,7 +73,7 @@ export function TabBar() {
|
||||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
|
||||
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -85,14 +89,20 @@ export function TabBar() {
|
||||
);
|
||||
}
|
||||
|
||||
function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
|
||||
function FadeOverlay({
|
||||
direction,
|
||||
show,
|
||||
}: {
|
||||
direction: "top" | "bottom";
|
||||
show: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
|
||||
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "top-0 bg-gradient-to-b from-panel to-transparent"
|
||||
: "bottom-0 bg-gradient-to-t from-panel to-transparent"
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
+21
-34
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { useFileUpload } from "@opencut/hooks/use-file-upload";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
Music,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useMediaPanelStore } from "../store";
|
||||
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
@@ -72,15 +72,14 @@ export function MediaView() {
|
||||
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
|
||||
const { highlightedId, registerElement } = useHighlightScroll(
|
||||
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
@@ -95,9 +94,10 @@ export function MediaView() {
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p),
|
||||
);
|
||||
const processedItems = await processMediaFiles({
|
||||
files: files as FileList,
|
||||
onProgress: (p: { progress: number }) => setProgress(p.progress),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
}
|
||||
@@ -110,17 +110,12 @@ export function MediaView() {
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: processFiles,
|
||||
});
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -260,27 +255,19 @@ export function MediaView() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* native file picker, visually hidden */}
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="bg-panel p-3 pb-2">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
|
||||
>
|
||||
@@ -427,7 +414,7 @@ export function MediaView() {
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
onClick={openFilePicker}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
-6
@@ -2,10 +2,6 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import {
|
||||
Loader2,
|
||||
Grid3X3,
|
||||
@@ -35,9 +31,7 @@ import {
|
||||
POPULAR_COLLECTIONS,
|
||||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import Image from "next/image";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { StickerCategory } from "@/stores/stickers-store";
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -63,7 +63,7 @@ export function SnapIndicator({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-90"
|
||||
className="z-90 pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
@@ -71,7 +71,7 @@ export function SnapIndicator({
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
|
||||
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../../ui/context-menu";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "../snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useScrollSync } from "@/hooks/use-scroll-sync";
|
||||
import { useTimelineInteractions } from "@/hooks/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/use-timeline-drag-drop";
|
||||
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
|
||||
export function Timeline() {
|
||||
@@ -45,10 +45,7 @@ export function Timeline() {
|
||||
dragState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
|
||||
const { addElementToNewTrack } = useTimelineStore();
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
addElementToNewTrack,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
@@ -59,6 +56,10 @@ export function Timeline() {
|
||||
isInTimeline,
|
||||
});
|
||||
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
// Dynamic timeline width calculation based on playhead position and duration
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
Eye,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "../audio-waveform";
|
||||
import { TimelineElementProps } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
|
||||
|
||||
export function TimelineElement({
|
||||
element,
|
||||
@@ -36,6 +38,7 @@ export function TimelineElement({
|
||||
onElementClick,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
const {
|
||||
dragState,
|
||||
copySelected,
|
||||
@@ -45,8 +48,6 @@ export function TimelineElement({
|
||||
toggleSelectedHidden,
|
||||
toggleSelectedMuted,
|
||||
duplicateElement,
|
||||
revealElementInMedia,
|
||||
replaceElementWithFile,
|
||||
getContextMenuState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
@@ -129,24 +130,11 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceClip = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*,audio/*,image/*";
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
await replaceElementWithFile(track.id, element.id, file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleRevealInMedia = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
revealElementInMedia(element.id);
|
||||
if (element.type === "media") {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderElementContent = () => {
|
||||
@@ -354,15 +342,20 @@ export function TimelineElement({
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
|
||||
{!isMultipleSelected && element.type === "media" && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleReplaceClip}>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Replace clip
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
@@ -91,12 +91,12 @@ export function TimelinePlayhead({
|
||||
const leftBoundary = trackLabelsWidth;
|
||||
const rightBoundary = Math.min(
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
|
||||
trackLabelsWidth + viewportWidth // Don't go beyond viewport
|
||||
trackLabelsWidth + viewportWidth, // Don't go beyond viewport
|
||||
);
|
||||
|
||||
const leftPosition = Math.max(
|
||||
leftBoundary,
|
||||
Math.min(rightBoundary, rawLeftPosition)
|
||||
Math.min(rightBoundary, rawLeftPosition),
|
||||
);
|
||||
|
||||
// Debug logging when playhead might go outside
|
||||
@@ -115,14 +115,14 @@ export function TimelinePlayhead({
|
||||
timelineContentWidth,
|
||||
viewportWidth,
|
||||
zoomLevel,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-40"
|
||||
className="pointer-events-auto absolute z-40"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
@@ -133,12 +133,12 @@ export function TimelinePlayhead({
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
className={`absolute left-0 h-full w-0.5 cursor-col-resize ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,7 +58,6 @@ export function TimelineToolbar({
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
@@ -140,20 +139,6 @@ export function TimelineToolbar({
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
};
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
separateAudio(trackId, elementId);
|
||||
};
|
||||
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
@@ -211,7 +196,11 @@ export function TimelineToolbar({
|
||||
/
|
||||
</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode(duration, "HH:MM:SS:FF")}
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{tracks.length === 0 && (
|
||||
@@ -278,11 +267,11 @@ export function TimelineToolbar({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
||||
<Button variant="text" size="icon" disabled>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { TimelineElement } from "./timeline-element";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
TrackType,
|
||||
} from "@/types/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
@@ -35,14 +28,10 @@ export function TimelineTrackContent({
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
moveElementToTrack,
|
||||
updateElementStartTime,
|
||||
updateElementStartTimeWithRipple,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
dragState,
|
||||
@@ -50,78 +39,18 @@ export function TimelineTrackContent({
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
insertTrackAt,
|
||||
snappingEnabled,
|
||||
rippleEditingEnabled,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime, duration } = usePlaybackStore();
|
||||
const { duration } = usePlaybackStore();
|
||||
|
||||
// Initialize snapping hook
|
||||
const { snapElementEdge } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
});
|
||||
|
||||
// Helper function for drop snapping that tries both edges
|
||||
const getDropSnappedTime = (
|
||||
dropTime: number,
|
||||
elementDuration: number,
|
||||
excludeElementId?: string,
|
||||
) => {
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Try snapping both start and end edges for drops
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true, // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false, // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
}
|
||||
}
|
||||
|
||||
return finalTime;
|
||||
};
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -159,70 +88,12 @@ export function TimelineTrackContent({
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: adjustedTime, fps: projectFps });
|
||||
let snapPoint = null;
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Find the element being dragged to get its duration
|
||||
let elementDuration = 5; // fallback duration
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(e) => e.id === dragState.elementId,
|
||||
);
|
||||
if (element) {
|
||||
elementDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Try snapping both start and end edges
|
||||
const startSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
true, // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
false, // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
snapPoint = bestSnapResult.snapPoint;
|
||||
}
|
||||
|
||||
// Notify parent component about snap point change
|
||||
onSnapPointChange?.(snapPoint);
|
||||
} else {
|
||||
// Clear snap point when element snapping is disabled
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
const finalTime = snapTimeToFrame({
|
||||
time: adjustedTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
updateDragTime(finalTime);
|
||||
};
|
||||
@@ -309,26 +180,7 @@ export function TimelineTrackContent({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
moveElementToTrack(
|
||||
dragState.trackId,
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime,
|
||||
);
|
||||
}
|
||||
});
|
||||
toast.info("Moving elements between tracks is coming soon!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +251,6 @@ export function TimelineTrackContent({
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateElementStartTime,
|
||||
moveElementToTrack,
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
@@ -495,620 +346,6 @@ export function TimelineTrackContent({
|
||||
// If element is already selected, keep it selected (do nothing)
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline elements and media items
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container",
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
// Text elements have default duration of 5 seconds
|
||||
const newElementDuration = 5;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
});
|
||||
} else {
|
||||
// Media elements
|
||||
const mediaItem = mediaFiles.find(
|
||||
(item) => item.id === dragData.id,
|
||||
);
|
||||
if (mediaItem) {
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineElement) {
|
||||
try {
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
if (timelineElementData) {
|
||||
const { elementId, trackId: fromTrackId } =
|
||||
JSON.parse(timelineElementData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId,
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: any) => c.id === elementId,
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
movingElementDuration,
|
||||
elementId,
|
||||
);
|
||||
const movingElementEnd = snappedTime + movingElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: "Drop event started in timeline track",
|
||||
dataTransferTypes: Array.from(e.dataTransfer.types),
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container",
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
|
||||
const newStartTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({
|
||||
time: newStartTime,
|
||||
fps: projectFps,
|
||||
});
|
||||
|
||||
// Calculate drop position relative to tracks
|
||||
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
|
||||
|
||||
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
|
||||
let dropPosition: "above" | "on" | "below";
|
||||
if (mouseY < 20) {
|
||||
dropPosition = "above";
|
||||
} else if (mouseY > 40) {
|
||||
dropPosition = "below";
|
||||
} else {
|
||||
dropPosition = "on";
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasTimelineElement) {
|
||||
// Handle timeline element movement
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element",
|
||||
);
|
||||
if (!timelineElementData) return;
|
||||
|
||||
const {
|
||||
elementId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineElementData);
|
||||
|
||||
// Find the element being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId,
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: TimelineElementType) => c.id === elementId,
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
toast.error("Element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing elements (excluding the moving element itself)
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
|
||||
// Adjust position based on where user clicked on the element
|
||||
const adjustedStartTime = newStartTime - clickOffsetTime;
|
||||
const snappedStartTime = getDropSnappedTime(
|
||||
adjustedStartTime,
|
||||
movingElementDuration,
|
||||
elementId,
|
||||
);
|
||||
const finalStartTime = Math.max(0, snappedStartTime);
|
||||
const movingElementEnd = finalStartTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
// Skip the element being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
finalStartTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveElementToTrack(fromTrackId, track.id, elementId);
|
||||
requestAnimationFrame(() => {
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime,
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
let targetTrackId = track.id;
|
||||
let targetTrack = track;
|
||||
|
||||
// Handle position-aware track creation for text
|
||||
if (track.type !== "text" || dropPosition !== "on") {
|
||||
// Text tracks should go above the main track
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// dropPosition === "on" but track is not text type
|
||||
// Insert above main track if main track exists, otherwise at top
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
} else {
|
||||
insertIndex = 0; // Top of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("text", insertIndex);
|
||||
// Get the updated tracks array after creating the new track
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = 5; // Default text duration
|
||||
const textSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = textSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
textSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
|
||||
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
|
||||
startTime: textSnappedTime,
|
||||
});
|
||||
} else {
|
||||
// Handle media items
|
||||
const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTrackId = track.id;
|
||||
|
||||
// Check if track type is compatible
|
||||
const isVideoOrImage =
|
||||
dragData.type === "video" || dragData.type === "image";
|
||||
const isAudio = dragData.type === "audio";
|
||||
const isCompatible = isVideoOrImage
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: isAudio
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: false;
|
||||
|
||||
let targetTrack = tracks.find((t) => t.id === targetTrackId);
|
||||
|
||||
// Handle position-aware track creation for media elements
|
||||
if (!isCompatible || dropPosition !== "on") {
|
||||
if (isVideoOrImage) {
|
||||
// For video/image, check if we need a main track or additional media track
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
|
||||
if (!mainTrack) {
|
||||
// No main track exists, create it
|
||||
targetTrackId = addTrack("media");
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
} else if (
|
||||
mainTrack.elements.length === 0 &&
|
||||
dropPosition === "on"
|
||||
) {
|
||||
// Main track exists and is empty, use it
|
||||
targetTrackId = mainTrack.id;
|
||||
targetTrack = mainTrack;
|
||||
} else {
|
||||
// Create new media track
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert above main track
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("media", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
} else if (isAudio) {
|
||||
// Audio tracks go at the bottom
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert after main track (bottom area)
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id,
|
||||
);
|
||||
insertIndex = mainTrackIndex + 1;
|
||||
} else {
|
||||
insertIndex = tracks.length; // Bottom of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("audio", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId,
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrack) return;
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const mediaSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration,
|
||||
);
|
||||
const newElementEnd = mediaSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
mediaSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: mediaSnappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
// External file drops
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
const { addMediaFile } = useMediaStore.getState();
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and add files to new timeline tracks at playhead position
|
||||
processMediaFiles(e.dataTransfer.files)
|
||||
.then(async (processedItems) => {
|
||||
for (const processedItem of processedItems) {
|
||||
await addMediaFile(activeProject.id, processedItem);
|
||||
const currentMediaFiles = mediaFiles;
|
||||
const addedItem = currentMediaFiles.find(
|
||||
(item) =>
|
||||
item.name === processedItem.name &&
|
||||
item.url === processedItem.url,
|
||||
);
|
||||
|
||||
if (addedItem) {
|
||||
const trackType: TrackType =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = insertTrackAt(trackType, 0);
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error processing external files:", error);
|
||||
toast.error("Failed to process dropped files");
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hover:bg-muted/20 h-full w-full"
|
||||
@@ -1118,10 +355,7 @@ export function TimelineTrackContent({
|
||||
clearSelectedElements();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
{...dragProps}
|
||||
>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
@@ -1130,14 +364,14 @@ export function TimelineTrackContent({
|
||||
{track.elements.length === 0 ? (
|
||||
<div
|
||||
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
|
||||
isDropping
|
||||
isDragOver
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
{isDragOver
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop element here"
|
||||
@@ -1150,40 +384,6 @@ export function TimelineTrackContent({
|
||||
(c) => c.trackId === track.id && c.elementId === element.id,
|
||||
);
|
||||
|
||||
const handleElementSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { splitSelected } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
splitSelected(splitTime, track.id, element.id);
|
||||
} else {
|
||||
toast.error("Playhead must be within element to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDuplicate = () => {
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(track.id, {
|
||||
...elementWithoutId,
|
||||
name: element.name + " (copy)",
|
||||
startTime:
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleElementDelete = () => {
|
||||
const { deleteSelected } = useTimelineStore.getState();
|
||||
deleteSelected(track.id, element.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<TimelineElement
|
||||
key={element.id}
|
||||
|
||||
@@ -152,18 +152,6 @@ export class TimelineManager {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
moveElementToTrack({
|
||||
fromTrackId,
|
||||
toTrackId,
|
||||
elementId,
|
||||
}: {
|
||||
fromTrackId: string;
|
||||
toTrackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementTrim({
|
||||
trackId,
|
||||
elementId,
|
||||
@@ -236,28 +224,6 @@ export class TimelineManager {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
separateAudio({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): string | null {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
replaceElementMedia({
|
||||
trackId,
|
||||
elementId,
|
||||
newFile,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
newFile: File;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementStartTimeWithRipple({
|
||||
trackId,
|
||||
elementId,
|
||||
@@ -394,18 +360,6 @@ export class TimelineManager {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async replaceElementWithFile({
|
||||
trackId,
|
||||
elementId,
|
||||
file,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
file: File;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getContextMenuState({
|
||||
trackId,
|
||||
elementId,
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { toast } from "sonner";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time-utils";
|
||||
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import {
|
||||
useTimelineSnapping,
|
||||
SnapPoint,
|
||||
} from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
track?: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const [dropPositionIndicator, setDropPositionIndicator] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const { mediaFiles, addMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const {
|
||||
tracks,
|
||||
addElementToTrack,
|
||||
insertTrackAt,
|
||||
addTrack,
|
||||
snappingEnabled,
|
||||
} = useTimelineStore();
|
||||
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const { snapElementEdge } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
});
|
||||
|
||||
const getDropSnappedTime = useCallback(
|
||||
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
if (snappingEnabled) {
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true,
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false,
|
||||
);
|
||||
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
}
|
||||
}
|
||||
|
||||
return finalTime;
|
||||
},
|
||||
[
|
||||
activeProject?.fps,
|
||||
snappingEnabled,
|
||||
snapElementEdge,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
|
||||
if (!hasMediaItem && !hasFiles) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
},
|
||||
[isDragOver],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!hasMediaItem) return;
|
||||
|
||||
if (track) {
|
||||
const trackContainer =
|
||||
(e.currentTarget as HTMLElement).closest(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement).querySelector(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement);
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const dropTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
let overlap = false;
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
const duration =
|
||||
dragData.type === "text"
|
||||
? 5
|
||||
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
|
||||
const snappedTime = getDropSnappedTime(dropTime, duration);
|
||||
const endTime = snappedTime + duration;
|
||||
|
||||
overlap = track.elements.some((el) => {
|
||||
const elEnd =
|
||||
el.startTime + (el.duration - el.trimStart - el.trimEnd);
|
||||
return snappedTime < elEnd && endTime > el.startTime;
|
||||
});
|
||||
}
|
||||
} catch (f) {}
|
||||
|
||||
setWouldOverlap(overlap);
|
||||
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
|
||||
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
|
||||
}
|
||||
},
|
||||
[track, zoomLevel, mediaFiles, getDropSnappedTime],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current <= 0) {
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPositionIndicator(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPositionIndicator(null);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item",
|
||||
);
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasMediaItem && !hasFiles) return;
|
||||
|
||||
const trackContainer =
|
||||
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
|
||||
(e.currentTarget as HTMLElement).querySelector(
|
||||
".track-elements-container",
|
||||
) ||
|
||||
(e.currentTarget as HTMLElement);
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const dropTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
const projectFps = activeProject?.fps || DEFAULT_FPS;
|
||||
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
|
||||
|
||||
let dropPos: "above" | "on" | "below" = "on";
|
||||
if (track) {
|
||||
if (mouseY < 20) dropPos = "above";
|
||||
else if (mouseY > 40) dropPos = "below";
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasMediaItem) {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item",
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
let targetTrackId = track?.id;
|
||||
let targetTrack = track;
|
||||
|
||||
if (!track || track.type !== "text" || dropPos !== "on") {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
let insertIndex = 0;
|
||||
if (track) {
|
||||
const currentIdx = tracks.findIndex((t) => t.id === track.id);
|
||||
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
|
||||
} else if (mainTrack) {
|
||||
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
|
||||
}
|
||||
targetTrackId = insertTrackAt("text", insertIndex);
|
||||
targetTrack = useTimelineStore
|
||||
.getState()
|
||||
.tracks.find((t) => t.id === targetTrackId);
|
||||
}
|
||||
|
||||
if (!targetTrack || !targetTrackId) return;
|
||||
const duration = 5;
|
||||
const finalStart = getDropSnappedTime(dropTime, duration);
|
||||
const finalEnd = finalStart + duration;
|
||||
|
||||
if (
|
||||
targetTrack.elements.some(
|
||||
(el) =>
|
||||
finalStart <
|
||||
el.startTime + el.duration - el.trimStart - el.trimEnd &&
|
||||
finalEnd > el.startTime,
|
||||
)
|
||||
) {
|
||||
toast.error("Cannot place element here - overlap detected");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
|
||||
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
|
||||
startTime: finalStart,
|
||||
});
|
||||
} else {
|
||||
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
|
||||
if (!mediaItem) return;
|
||||
|
||||
let targetTrackId = track?.id;
|
||||
const isVideoOrImage =
|
||||
dragData.type === "video" || dragData.type === "image";
|
||||
const isAudio = dragData.type === "audio";
|
||||
const isCompatible = track
|
||||
? isVideoOrImage
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: isAudio
|
||||
? canElementGoOnTrack({
|
||||
elementType: "media",
|
||||
trackType: track.type,
|
||||
})
|
||||
: false
|
||||
: false;
|
||||
|
||||
let targetTrack = track;
|
||||
|
||||
if (!track || !isCompatible || dropPos !== "on") {
|
||||
if (isVideoOrImage) {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack) {
|
||||
targetTrackId = addTrack("media");
|
||||
} else if (
|
||||
mainTrack.elements.length === 0 &&
|
||||
(!track || dropPos === "on")
|
||||
) {
|
||||
targetTrackId = mainTrack.id;
|
||||
} else {
|
||||
let idx = track
|
||||
? tracks.findIndex((t) => t.id === track.id)
|
||||
: 0;
|
||||
if (track) idx = dropPos === "above" ? idx : idx + 1;
|
||||
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
|
||||
targetTrackId = insertTrackAt("media", idx);
|
||||
}
|
||||
} else if (isAudio) {
|
||||
let idx = track
|
||||
? tracks.findIndex((t) => t.id === track.id)
|
||||
: tracks.length;
|
||||
if (track) idx = dropPos === "above" ? idx : idx + 1;
|
||||
targetTrackId = insertTrackAt("audio", idx);
|
||||
}
|
||||
targetTrack = useTimelineStore
|
||||
.getState()
|
||||
.tracks.find((t) => t.id === targetTrackId);
|
||||
}
|
||||
|
||||
if (!targetTrack || !targetTrackId) return;
|
||||
const duration = mediaItem.duration || 5;
|
||||
const finalStart = getDropSnappedTime(dropTime, duration);
|
||||
const finalEnd = finalStart + duration;
|
||||
|
||||
if (
|
||||
targetTrack.elements.some(
|
||||
(el) =>
|
||||
finalStart <
|
||||
el.startTime + el.duration - el.trimStart - el.trimEnd &&
|
||||
finalEnd > el.startTime,
|
||||
)
|
||||
) {
|
||||
toast.error("Cannot place element here - overlap detected");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration,
|
||||
startTime: finalStart,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
if (!activeProject) return;
|
||||
const processedItems = await processMediaFiles({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
const added = useMediaStore
|
||||
.getState()
|
||||
.mediaFiles.find(
|
||||
(m) => m.name === item.name && m.url === item.url,
|
||||
);
|
||||
if (added) {
|
||||
const type: TrackType =
|
||||
added.type === "audio" ? "audio" : "media";
|
||||
const tid = insertTrackAt(type, 0);
|
||||
addElementToTrack(tid, {
|
||||
type: "media",
|
||||
mediaId: added.id,
|
||||
name: added.name,
|
||||
duration: added.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to process drop");
|
||||
}
|
||||
},
|
||||
[
|
||||
track,
|
||||
zoomLevel,
|
||||
activeProject,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
currentTime,
|
||||
getDropSnappedTime,
|
||||
addElementToTrack,
|
||||
insertTrackAt,
|
||||
addTrack,
|
||||
addMediaFile,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
wouldOverlap,
|
||||
dropPositionIndicator,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface UseDragDropOptions {
|
||||
onDrop?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
// Helper function to check if drag contains files from external sources (not internal app drags)
|
||||
const containsFiles = (dataTransfer: DataTransfer): boolean => {
|
||||
// Check if this is an internal app drag (media item)
|
||||
if (dataTransfer.types.includes("application/x-media-item")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show overlay for external file drags
|
||||
return dataTransfer.types.includes("Files");
|
||||
};
|
||||
|
||||
export function useDragDrop(options: UseDragDropOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle external file drags, not internal app element drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Only handle file drags
|
||||
if (!containsFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
// Only handle file drops
|
||||
if (
|
||||
options.onDrop &&
|
||||
e.dataTransfer.files &&
|
||||
containsFiles(e.dataTransfer)
|
||||
) {
|
||||
options.onDrop(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const dragProps = {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps,
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const usePlaybackControls = () => {
|
||||
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
|
||||
|
||||
const {
|
||||
selectedElements,
|
||||
tracks,
|
||||
splitSelected,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
|
||||
const handleSplitSelectedElement = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitSelected(currentTime, trackId, elementId);
|
||||
}, [selectedElements, tracks, currentTime, splitSelected]);
|
||||
|
||||
const handleSplitAndKeepLeftCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
|
||||
|
||||
const handleSplitAndKeepRightCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
|
||||
|
||||
const handleSeparateAudioCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
export function useProjectInitialization({ projectId }: { projectId: string }) {
|
||||
const {
|
||||
activeProject,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
} = useProjectStore();
|
||||
const router = useRouter();
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const initProject = async () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProject?.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInvalidProjectId(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledProjectIds.current.has(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializingRef.current = true;
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitializingRef.current = false;
|
||||
} catch (error) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProjectNotFound =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("not found") ||
|
||||
error.message.includes("does not exist") ||
|
||||
error.message.includes("Project not found"));
|
||||
|
||||
if (isProjectNotFound) {
|
||||
markProjectIdAsInvalid(projectId);
|
||||
|
||||
try {
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createError) {
|
||||
console.error("Failed to create new project:", createError);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Project loading failed with recoverable error:",
|
||||
error,
|
||||
);
|
||||
handledProjectIds.current.delete(projectId);
|
||||
}
|
||||
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initProject();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function useHighlightScroll(
|
||||
export function useRevealItem(
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
highlightDuration = 1000
|
||||
highlightDuration = 1000,
|
||||
) {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { toast } from "sonner";
|
||||
import type { DragData } from "@/types/timeline";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
addElementToNewTrack: (data: any) => void;
|
||||
}
|
||||
|
||||
export function useTimelineDragDrop({ addElementToNewTrack }: UseTimelineDragDropProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { mediaFiles, addMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleInternalMediaDrop = useCallback(async (dragData: DragData) => {
|
||||
if (dragData.type === "text") {
|
||||
addElementToNewTrack(dragData);
|
||||
} else {
|
||||
const mediaItem = mediaFiles.find((item: any) => item.id === dragData.id);
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToNewTrack(mediaItem);
|
||||
}
|
||||
}, [mediaFiles, addElementToNewTrack]);
|
||||
|
||||
const handleExternalFileDrop = useCallback(async (files: FileList) => {
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const processedItems = await processMediaFiles({
|
||||
files,
|
||||
});
|
||||
|
||||
for (const processedItem of processedItems) {
|
||||
await addMediaFile(activeProject.id, processedItem);
|
||||
|
||||
const addedItem = mediaFiles.find(
|
||||
(item) =>
|
||||
item.name === processedItem.name && item.url === processedItem.url,
|
||||
);
|
||||
|
||||
if (addedItem) {
|
||||
const trackType: "audio" | "media" =
|
||||
addedItem.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = useTimelineStore
|
||||
.getState()
|
||||
.insertTrackAt(trackType, 0);
|
||||
|
||||
useTimelineStore.getState().addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing external files:", error);
|
||||
toast.error("Failed to process dropped files");
|
||||
}
|
||||
}, [activeProject, mediaFiles, addMediaFile, currentTime]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (!isDragOver) setIsDragOver(true);
|
||||
}, [isDragOver]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemData = e.dataTransfer.getData("application/x-media-item");
|
||||
if (itemData) {
|
||||
const dragData: DragData = JSON.parse(itemData);
|
||||
await handleInternalMediaDrop(dragData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.dataTransfer.files?.length > 0) {
|
||||
await handleExternalFileDrop(e.dataTransfer.files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped item data:", error);
|
||||
toast.error("Failed to add item to timeline");
|
||||
}
|
||||
}, [handleInternalMediaDrop, handleExternalFileDrop]);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
interface AssetsPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
highlightMediaId: string | null;
|
||||
@@ -76,7 +76,7 @@ interface MediaPanelStore {
|
||||
clearHighlight: () => void;
|
||||
}
|
||||
|
||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
highlightMediaId: null,
|
||||
@@ -60,7 +60,6 @@ export const getImageDimensions = (
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to generate video thumbnail and get dimensions
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
ensureMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { MediaFile, MediaType } from "@/types/media";
|
||||
import { generateVideoThumbnail, useMediaStore } from "./media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { useSceneStore } from "./scene-store";
|
||||
@@ -23,7 +23,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { usePlaybackStore } from "./playback-store";
|
||||
|
||||
// Helper function to manage element naming with suffixes
|
||||
const getElementNameWithSuffix = (
|
||||
originalName: string,
|
||||
@@ -103,11 +102,6 @@ interface TimelineStore {
|
||||
removeTrackWithRipple: (trackId: string) => void;
|
||||
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
|
||||
|
||||
moveElementToTrack: (
|
||||
fromTrackId: string,
|
||||
toTrackId: string,
|
||||
elementId: string,
|
||||
) => void;
|
||||
updateElementTrim: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
@@ -138,14 +132,6 @@ interface TimelineStore {
|
||||
elementId: string,
|
||||
splitTime: number,
|
||||
) => void;
|
||||
separateAudio: (trackId: string, elementId: string) => string | null;
|
||||
|
||||
// Replace media for an element
|
||||
replaceElementMedia: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
newFile: File,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Ripple editing functions
|
||||
updateElementStartTimeWithRipple: (
|
||||
@@ -199,12 +185,6 @@ interface TimelineStore {
|
||||
toggleSelectedHidden: (trackId?: string, elementId?: string) => void;
|
||||
toggleSelectedMuted: (trackId?: string, elementId?: string) => void;
|
||||
duplicateElement: (trackId: string, elementId: string) => void;
|
||||
revealElementInMedia: (elementId: string) => void;
|
||||
replaceElementWithFile: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
file: File,
|
||||
) => Promise<void>;
|
||||
getContextMenuState: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
@@ -690,49 +670,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
updateTracksAndSave(updatedTracks);
|
||||
},
|
||||
|
||||
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
|
||||
get().pushHistory();
|
||||
|
||||
const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
|
||||
const toTrack = get()._tracks.find((track) => track.id === toTrackId);
|
||||
const elementToMove = fromTrack?.elements.find(
|
||||
(element) => element.id === elementId,
|
||||
);
|
||||
|
||||
if (!elementToMove || !toTrack) return;
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element: elementToMove,
|
||||
track: toTrack,
|
||||
});
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const newTracks = get()
|
||||
._tracks.map((track) => {
|
||||
if (track.id === fromTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.filter(
|
||||
(element) => element.id !== elementId,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: [...track.elements, elementToMove],
|
||||
};
|
||||
}
|
||||
return track;
|
||||
})
|
||||
.filter((track) => track.elements.length > 0);
|
||||
|
||||
updateTracksAndSave(newTracks);
|
||||
},
|
||||
|
||||
updateElementTrim: (
|
||||
trackId,
|
||||
elementId,
|
||||
@@ -988,190 +925,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
// Extract audio from video element to an audio track
|
||||
separateAudio: (trackId, elementId) => {
|
||||
const { _tracks } = get();
|
||||
const track = _tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
|
||||
if (!element || track?.type !== "media") return null;
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
|
||||
const audioElementId = generateUUID();
|
||||
|
||||
if (existingAudioTrack) {
|
||||
updateTracksAndSave(
|
||||
get()._tracks.map((track) =>
|
||||
track.id === existingAudioTrack.id
|
||||
? {
|
||||
...track,
|
||||
elements: [
|
||||
...track.elements,
|
||||
{
|
||||
...element,
|
||||
id: audioElementId,
|
||||
name: getElementNameWithSuffix(element.name, "audio"),
|
||||
},
|
||||
],
|
||||
}
|
||||
: track,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const newAudioTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
name: "Audio Track",
|
||||
type: "audio",
|
||||
elements: [
|
||||
{
|
||||
...element,
|
||||
id: audioElementId,
|
||||
name: getElementNameWithSuffix(element.name, "audio"),
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
};
|
||||
|
||||
updateTracksAndSave([...get()._tracks, newAudioTrack]);
|
||||
}
|
||||
|
||||
return audioElementId;
|
||||
},
|
||||
|
||||
// Replace media for an element
|
||||
replaceElementMedia: async (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
newFile: File,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const { _tracks } = get();
|
||||
const track = _tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((c) => c.id === elementId);
|
||||
|
||||
if (!element) {
|
||||
return { success: false, error: "Timeline element not found" };
|
||||
}
|
||||
|
||||
if (element.type !== "media") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Replace is only available for media clips",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
|
||||
if (!projectStore.activeProject) {
|
||||
return { success: false, error: "No active project found" };
|
||||
}
|
||||
|
||||
const {
|
||||
getFileType,
|
||||
getImageDimensions,
|
||||
generateVideoThumbnail,
|
||||
getMediaDuration,
|
||||
} = await import("./media-store");
|
||||
|
||||
const fileType = getFileType(newFile);
|
||||
if (!fileType) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Unsupported file type. Please select a video, audio, or image file.",
|
||||
};
|
||||
}
|
||||
|
||||
const mediaData: Omit<MediaFile, "id"> = {
|
||||
name: newFile.name,
|
||||
type: fileType as MediaType,
|
||||
file: newFile,
|
||||
url: URL.createObjectURL(newFile),
|
||||
};
|
||||
|
||||
try {
|
||||
if (fileType === "image") {
|
||||
const { width, height } = await getImageDimensions(newFile);
|
||||
mediaData.width = width;
|
||||
mediaData.height = height;
|
||||
} else if (fileType === "video") {
|
||||
const [duration, { thumbnailUrl, width, height }] =
|
||||
await Promise.all([
|
||||
getMediaDuration(newFile),
|
||||
generateVideoThumbnail(newFile),
|
||||
]);
|
||||
mediaData.duration = duration;
|
||||
mediaData.thumbnailUrl = thumbnailUrl;
|
||||
mediaData.width = width;
|
||||
mediaData.height = height;
|
||||
} else if (fileType === "audio") {
|
||||
mediaData.duration = await getMediaDuration(newFile);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to process ${fileType} file: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mediaStore.addMediaFile(
|
||||
projectStore.activeProject.id,
|
||||
mediaData,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to add media to project: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
|
||||
const newMediaItem = mediaStore.mediaFiles.find(
|
||||
(item) => item.file === newFile,
|
||||
);
|
||||
|
||||
if (!newMediaItem) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to create media item in project. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
updateTracksAndSave(
|
||||
_tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
elements: track.elements.map((c) =>
|
||||
c.id === elementId
|
||||
? {
|
||||
...c,
|
||||
mediaId: newMediaItem.id,
|
||||
name: newMediaItem.name,
|
||||
duration: newMediaItem.duration || c.duration,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
}
|
||||
: track,
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to replace element media:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getTotalDuration: () => {
|
||||
const { _tracks } = get();
|
||||
if (_tracks.length === 0) return 0;
|
||||
@@ -1226,9 +979,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
if (!mediaFile) return null;
|
||||
|
||||
if (mediaFile.type === "video" && mediaFile.file) {
|
||||
const { generateVideoThumbnail } = await import(
|
||||
"@/stores/media-store"
|
||||
);
|
||||
const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
|
||||
return thumbnailUrl;
|
||||
}
|
||||
@@ -1778,45 +1528,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
} as CreateTimelineElement);
|
||||
},
|
||||
|
||||
revealElementInMedia: (elementId) => {
|
||||
const {
|
||||
useMediaPanelStore,
|
||||
} = require("../components/editor/media-panel/store");
|
||||
const { requestRevealMedia } = useMediaPanelStore.getState();
|
||||
|
||||
const { _tracks } = get();
|
||||
const element = _tracks
|
||||
.flatMap((track) => track.elements)
|
||||
.find((el) => el.id === elementId);
|
||||
|
||||
if (element?.type === "media") {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
},
|
||||
|
||||
replaceElementWithFile: async (trackId, elementId, file) => {
|
||||
try {
|
||||
const result = await get().replaceElementMedia(
|
||||
trackId,
|
||||
elementId,
|
||||
file,
|
||||
);
|
||||
if (result.success) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.success("Clip replaced successfully");
|
||||
} else {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(result.error || "Failed to replace clip");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unexpected error replacing clip:", error);
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
getContextMenuState: (trackId, elementId) => {
|
||||
const { selectedElements, _tracks } = get();
|
||||
const { currentTime } = usePlaybackStore.getState();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"": {
|
||||
"name": "opencut",
|
||||
"dependencies": {
|
||||
"next": "^15.3.4",
|
||||
"next": "15.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.2",
|
||||
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
@@ -36,7 +37,7 @@
|
||||
"mediabunny": "^1.9.3",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.5.3",
|
||||
"next": "^15.5.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
@@ -91,6 +92,7 @@
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/hooks": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
@@ -112,7 +114,7 @@
|
||||
"mediabunny": "^1.9.3",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.5.3",
|
||||
"next": "15.5.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
@@ -174,6 +176,17 @@
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
"packages/hooks": {
|
||||
"name": "@opencut/hooks",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencut/ui",
|
||||
"version": "0.0.0",
|
||||
@@ -357,23 +370,23 @@
|
||||
|
||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||
|
||||
"@next/env": ["@next/env@15.4.2", "", {}, "sha512-kd7MvW3pAP7tmk1NaiX4yG15xb2l4gNhteKQxt3f+NGR22qwPymn9RBuv26QKfIKmfo6z2NpgU8W2RT0s0jlvg=="],
|
||||
"@next/env": ["@next/env@15.3.6", "", {}, "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ovqjR8NjCBdBf1U+R/Gvn0RazTtXS9n6wqs84iFaCS1NHbw9ksVE4dfmsYcLoyUVd9BWE0bjkphOWrrz8uz/uw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-I8d4W7tPqbdbHRI4z1iBfaoJIBrEG4fnWKIe+Rj1vIucNZ5cEinfwkBt3RcDF00bFRZRDpvKuDjgMFD3OyRBnw=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lvhz02dU3Ec5thzfQ2RCUeOFADjNkS/px1W7MBt7HMhf0/amMfT8Z/aXOwEA+cVWN7HSDRSUc8hHILoHmvajsg=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-v+5PPfL8UP+KKHS3Mox7QMoeFdMlaV0zeNMIF7eLC4qTiVSO0RPNnK0nkBZSD5BEkkf//c+vI9s/iHxddCZchA=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-PHLYOC9W2cu6I/JEKo77+LW4uPNvyEQiSkVRUQPsOIsf01PRr8PtPhwtz3XNnC9At8CrzPkzqQ9/kYDg4R4Inw=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lpmUF9FfLFns4JbTu+5aJGA8aR9dXaA12eoNe9CJbVkGib0FDiPa4kBGTwy0xDxKNGlv3bLDViyx1U+qafmuJQ=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-aMjogoGnRepas0LQ/PBPsvvUzj+IoXw2IoDSEShEtrsu2toBiaxEWzOQuPZ8nie8+1iF7TA63S7rlp3YWAjNEg=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-FxwauyexSFu78wEqR/+NB9MnqXVj6SxJKwcVs2CRjeSX/jBagDCgtR2W36PZUYm0WPgY1pQ3C1+nn7zSnwROuw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
|
||||
|
||||
@@ -381,6 +394,8 @@
|
||||
|
||||
"@opencut/env": ["@opencut/env@workspace:packages/env"],
|
||||
|
||||
"@opencut/hooks": ["@opencut/hooks@workspace:packages/hooks"],
|
||||
|
||||
"@opencut/tools": ["@opencut/tools@workspace:apps/tools"],
|
||||
|
||||
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
|
||||
@@ -521,6 +536,8 @@
|
||||
|
||||
"@simplewebauthn/server": ["@simplewebauthn/server@13.1.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8" } }, "sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
@@ -555,7 +572,7 @@
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
|
||||
|
||||
@@ -597,7 +614,7 @@
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
@@ -631,7 +648,9 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
@@ -677,7 +696,7 @@
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
@@ -939,7 +958,7 @@
|
||||
|
||||
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
|
||||
|
||||
"next": ["next@15.4.2", "", { "dependencies": { "@next/env": "15.4.2", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.2", "@next/swc-darwin-x64": "15.4.2", "@next/swc-linux-arm64-gnu": "15.4.2", "@next/swc-linux-arm64-musl": "15.4.2", "@next/swc-linux-x64-gnu": "15.4.2", "@next/swc-linux-x64-musl": "15.4.2", "@next/swc-win32-arm64-msvc": "15.4.2", "@next/swc-win32-x64-msvc": "15.4.2", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-oH1rmFso+84NIkocfuxaGKcXIjMUTmnzV2x0m8qsYtB4gD6iflLMESXt5XJ8cFgWMBei4v88rNr/j+peNg72XA=="],
|
||||
"next": ["next@15.3.6", "", { "dependencies": { "@next/env": "15.3.6", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
@@ -999,7 +1018,7 @@
|
||||
|
||||
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"react-country-flag": ["react-country-flag@3.1.0", "", { "peerDependencies": { "react": ">=16" } }, "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g=="],
|
||||
|
||||
@@ -1083,6 +1102,8 @@
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="],
|
||||
@@ -1183,15 +1204,19 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@opencut/tools/next": ["next@15.5.3", "", { "dependencies": { "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-x64": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-musl": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw=="],
|
||||
"@opencut/tools/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
|
||||
|
||||
"@opencut/tools/next": ["next@15.5.9", "", { "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg=="],
|
||||
|
||||
"@opencut/tools/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@opencut/tools/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@opencut/ui/@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
"@opencut/web/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
|
||||
|
||||
"@opencut/ui/react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
"@opencut/web/next": ["next@15.5.7", "", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
|
||||
|
||||
"@opencut/web/next": ["next@15.5.3", "", { "dependencies": { "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-x64": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-musl": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw=="],
|
||||
"@opencut/web/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
@@ -1209,10 +1234,14 @@
|
||||
|
||||
"@types/pg/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="],
|
||||
|
||||
"@types/react-dom/@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
|
||||
|
||||
"@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="],
|
||||
|
||||
"better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
@@ -1221,6 +1250,10 @@
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
@@ -1267,45 +1300,47 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@opencut/tools/next/@next/env": ["@next/env@15.5.3", "", {}, "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw=="],
|
||||
"@opencut/tools/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg=="],
|
||||
"@opencut/tools/next/@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g=="],
|
||||
"@opencut/tools/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw=="],
|
||||
"@opencut/tools/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ=="],
|
||||
"@opencut/tools/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA=="],
|
||||
"@opencut/tools/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg=="],
|
||||
"@opencut/tools/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA=="],
|
||||
"@opencut/tools/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
|
||||
"@opencut/tools/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
|
||||
|
||||
"@opencut/tools/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
|
||||
|
||||
"@opencut/tools/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"@opencut/ui/@types/react/csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
"@opencut/web/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"@opencut/web/next/@next/env": ["@next/env@15.5.3", "", {}, "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw=="],
|
||||
"@opencut/web/next/@next/env": ["@next/env@15.5.7", "", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg=="],
|
||||
"@opencut/web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g=="],
|
||||
"@opencut/web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw=="],
|
||||
"@opencut/web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ=="],
|
||||
"@opencut/web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA=="],
|
||||
"@opencut/web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg=="],
|
||||
"@opencut/web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA=="],
|
||||
"@opencut/web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
|
||||
|
||||
"@opencut/web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
|
||||
"@opencut/web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
|
||||
|
||||
"@opencut/web/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
@@ -1313,6 +1348,8 @@
|
||||
|
||||
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@types/react-dom/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"@opencut/tools/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 900 KiB |
+1
-1
@@ -13,7 +13,7 @@
|
||||
"start:tools": "turbo run start --filter=@opencut/tools"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.3.4"
|
||||
"next": "15.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.2",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@opencut/hooks",
|
||||
"version": "0.0.0",
|
||||
"description": "Hooks package for OpenCut",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./use-file-upload": "./src/use-file-upload.ts",
|
||||
"./use-file-paste": "./src/use-file-paste.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./use-file-upload";
|
||||
export * from "./use-file-paste";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface UseFilePasteOptions {
|
||||
onFilesPaste: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function useFilePaste({ onFilesPaste }: UseFilePasteOptions) {
|
||||
useEffect(() => {
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
if (!e.clipboardData?.files.length) return;
|
||||
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onFilesPaste(files);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [onFilesPaste]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onFilesSelected?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
const isInternalDrag = dataTransfer.types.includes("application/x-media-item");
|
||||
const hasFiles = dataTransfer.types.includes("Files");
|
||||
|
||||
return !isInternalDrag && hasFiles;
|
||||
}
|
||||
|
||||
export function useFileUpload({ accept, multiple, onFilesSelected }: UseFileUploadOptions = {}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
inputRef.current.accept = accept || "*";
|
||||
inputRef.current.multiple = multiple || false;
|
||||
inputRef.current.click();
|
||||
}
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0 && onFilesSelected) {
|
||||
onFilesSelected(files);
|
||||
}
|
||||
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnter(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
dragCounterRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!containsFiles(e.dataTransfer)) return;
|
||||
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (onFilesSelected && containsFiles(e.dataTransfer)) {
|
||||
const files = e.dataTransfer.files;
|
||||
const shouldUseMultiple = multiple ?? false;
|
||||
|
||||
if (shouldUseMultiple) {
|
||||
onFilesSelected(files);
|
||||
} else if (files.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
onFilesSelected(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
openFilePicker,
|
||||
fileInputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
style: { display: "none" },
|
||||
onChange: handleFileChange,
|
||||
},
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -8,12 +8,12 @@
|
||||
".": "./src/index.ts",
|
||||
"./icons": "./src/icons/index.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user