This commit is contained in:
Maze Winther
2025-12-25 21:15:36 +01:00
parent 2da84e7132
commit d5ca991501
102 changed files with 16028 additions and 6675 deletions
+2 -1
View File
@@ -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

+10
View File
@@ -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

-53
View File
@@ -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>
);
}
+7
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
import { BGRemoverClient } from "./client-page";
export default function BGRemoverPage() {
return <BGRemoverClient />;
}
+1 -1
View File
@@ -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%);
+1 -1
View File
@@ -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 />
+7 -2
View File
@@ -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>
);
}
+57 -101
View File
@@ -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>
);
-2
View File
@@ -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:
+1 -1
View File
@@ -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}
+17
View File
@@ -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 };
+1 -1
View File
@@ -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",
-71
View File
@@ -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,
}),
},
),
);
-270
View File
@@ -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;
}
-296
View File
@@ -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: [] });
},
}));
-225
View File
@@ -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",
}
)
);
-198
View File
@@ -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();
}
},
}));
-465
View File
@@ -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() });
},
}));
-12
View File
@@ -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 }),
}));
-419
View File
@@ -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,
});
},
};
});
-282
View File
@@ -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;
}
},
}));
-231
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"pages": {}
}
+14
View File
@@ -0,0 +1,14 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"ampDevFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_ssgManifest.js",
"static/development/_buildManifest.js"
],
"rootMainFiles": [],
"ampFirstPages": []
}
+1
View File
@@ -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": []
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": [],
"preview": {
"previewModeId": "69359808c9a0e2ef1d49645b631e2df3",
"previewModeSigningKey": "3633002c1292b21a2ca6234134d5ac8399c49df49cb048069e9f8e2abb74e43f",
"previewModeEncryptionKey": "9ba5e3b1ce2abf798e2d8442c165a811960360c5a8cdc5fe7ebbe0df296d2d62"
}
}
+1
View File
@@ -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()
+2
View File
@@ -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"}]
+90
View File
@@ -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]>
}
}
+2 -1
View File
@@ -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",
+264 -377
View File
@@ -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>
);
}
+48 -49
View File
@@ -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>
@@ -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>
@@ -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",
)}
/>
);
@@ -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" ? (
@@ -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,
},
};
}
-85
View File
@@ -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,
};
}
-109
View File
@@ -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,
},
};
}
@@ -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,
-1
View File
@@ -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 }> => {
+2 -291
View File
@@ -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();
+78 -41
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

+1 -1
View File
@@ -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",
+19
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./use-file-upload";
export * from "./use-file-paste";
+23
View File
@@ -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]);
}
+101
View File
@@ -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,
},
};
}
+20
View File
@@ -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"]
}
+4 -4
View File
@@ -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