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
+3 -2
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",
@@ -80,4 +81,4 @@
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}
}
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