summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx144
-rw-r--r--apps/web/src/app/blog/page.tsx98
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx4
-rw-r--r--apps/web/src/app/metadata.ts3
-rw-r--r--apps/web/src/app/projects/page.tsx1044
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx5
-rw-r--r--apps/web/src/components/editor/media-panel/views/audio.tsx18
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx8
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx491
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx (renamed from apps/web/src/components/editor/timeline.tsx)727
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx (renamed from apps/web/src/components/editor/timeline-element.tsx)27
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx (renamed from apps/web/src/components/editor/timeline-playhead.tsx)0
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx (renamed from apps/web/src/components/editor/timeline-track.tsx)0
-rw-r--r--apps/web/src/components/footer.tsx5
-rw-r--r--apps/web/src/components/header.tsx45
-rw-r--r--apps/web/src/components/landing/hero.tsx131
-rw-r--r--apps/web/src/components/onboarding.tsx132
-rw-r--r--apps/web/src/components/ui/dialog.tsx4
-rw-r--r--apps/web/src/components/ui/prose.tsx22
-rw-r--r--apps/web/src/components/ui/scroll-area.tsx9
-rw-r--r--apps/web/src/components/ui/separator.tsx14
-rw-r--r--apps/web/src/constants/timeline-constants.ts10
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts1
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts56
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts6
-rw-r--r--apps/web/src/lib/blog-query.ts55
-rw-r--r--apps/web/src/middleware.ts24
-rw-r--r--apps/web/src/stores/keybindings-store.ts11
-rw-r--r--apps/web/src/stores/timeline-store.ts2540
-rw-r--r--apps/web/src/types/post.ts93
-rw-r--r--apps/web/src/types/timeline.ts12
31 files changed, 3245 insertions, 2494 deletions
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
new file mode 100644
index 00000000..0de1e0a8
--- /dev/null
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -0,0 +1,144 @@
+import { Header } from "@/components/header";
+import Prose from "@/components/ui/prose";
+import { Separator } from "@/components/ui/separator";
+import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
+import { Metadata } from "next";
+import Image from "next/image";
+import { notFound } from "next/navigation";
+
+type PageProps = {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+};
+
+export async function generateMetadata({
+ params,
+}: PageProps): Promise<Metadata> {
+ const slug = (await params).slug;
+
+ const data = await getSinglePost(slug);
+
+ if (!data || !data.post) return {};
+
+ return {
+ title: data.post.title,
+ description: data.post.description,
+ twitter: {
+ title: `${data.post.title}`,
+ description: `${data.post.description}`,
+ card: "summary_large_image",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ },
+ openGraph: {
+ type: "article",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ title: data.post.title,
+ description: data.post.description,
+ publishedTime: new Date(data.post.publishedAt).toISOString(),
+ authors: [
+ ...data.post.authors.map((author: { name: string }) => author.name),
+ ],
+ },
+ };
+}
+
+export async function generateStaticParams() {
+ const data = await getPosts();
+ if (!data || !data.posts.length) return [];
+
+ return data.posts.map((post) => ({
+ slug: post.slug,
+ }));
+}
+
+async function Page({ params }: PageProps) {
+ const slug = (await params).slug;
+ const data = await getSinglePost(slug);
+ if (!data || !data.post) return notFound();
+
+ const html = await processHtmlContent(data.post.content);
+
+ const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
+ "en-US",
+ {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }
+ );
+
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container max-w-3xl mx-auto px-4 py-16">
+ <div className="text-center mb-6">
+ {data.post.coverImage && (
+ <div className="relative aspect-video rounded-lg overflow-hidden mb-6">
+ <Image
+ src={data.post.coverImage}
+ alt={data.post.title}
+ loading="eager"
+ fill
+ className="object-cover rounded-lg"
+ />
+ </div>
+ )}
+ <div className="flex items-center justify-center mb-6">
+ <time dateTime={data.post.publishedAt.toString()}>
+ {formattedDate}
+ </time>
+ </div>
+
+ <h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
+ {data.post.title}
+ </h1>
+ <div className="flex items-center justify-center gap-2">
+ {data.post.authors[0] && (
+ <>
+ <Image
+ src={data.post.authors[0].image}
+ alt={data.post.authors[0].name}
+ width={36}
+ height={36}
+ loading="eager"
+ className="aspect-square shrink-0 size-8 rounded-full"
+ />
+ <p className="text-muted-foreground">
+ {data.post.authors[0].name}
+ </p>
+ </>
+ )}
+ </div>
+ </div>
+ <Separator />
+ <section className="mt-14">
+ <Prose html={html} />
+ </section>
+ </div>
+ </main>
+ </div>
+ );
+}
+
+export default Page;
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
new file mode 100644
index 00000000..ca553a67
--- /dev/null
+++ b/apps/web/src/app/blog/page.tsx
@@ -0,0 +1,98 @@
+import { Metadata } from "next";
+import { Header } from "@/components/header";
+import { Card, CardContent } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import Link from "next/link";
+import { getPosts } from "@/lib/blog-query";
+import Image from "next/image";
+
+export const metadata: Metadata = {
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
+};
+
+export default async function BlogPage() {
+ const data = await getPosts();
+ if (!data || !data.posts) return <div>No posts yet</div>;
+
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container max-w-3xl mx-auto px-4 py-16">
+ <div className="text-center mb-20">
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Blog
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Read the latest news and updates about OpenCut, the free and
+ open-source video editor.
+ </p>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ {data.posts.map((post) => (
+ <Link key={post.id} href={`/blog/${post.slug}`}>
+ <Card className="h-full hover:shadow-lg transition-shadow overflow-hidden">
+ {post.coverImage && (
+ <div className="relative aspect-video">
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ fill
+ className="object-cover rounded-xl"
+ />
+ </div>
+ )}
+
+ <CardContent className="p-6">
+ {post.authors && post.authors.length > 0 && (
+ <div className="flex items-center gap-2 mb-4">
+ {post.authors.map((author, index) => (
+ <div
+ key={author.id}
+ className="flex items-center gap-2"
+ >
+ <Avatar className="w-6 h-6">
+ <AvatarImage
+ src={author.image}
+ alt={author.name}
+ />
+ <AvatarFallback className="text-xs">
+ {author.name.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <span className="text-sm text-muted-foreground">
+ {author.name}
+ </span>
+ {index < post.authors.length - 1 && (
+ <span className="text-muted-foreground">•</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
+ <p className="text-muted-foreground">{post.description}</p>
+ </CardContent>
+ </Card>
+ </Link>
+ ))}
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index aa5b7315..26fdcf8a 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import {
ResizablePanelGroup,
@@ -37,7 +37,6 @@ export default function Editor() {
const router = useRouter();
const projectId = params.project_id as string;
const handledProjectIds = useRef<Set<string>>(new Set());
- const [isOnboardingOpen, setIsOnboardingOpen] = useState(true);
usePlaybackControls();
@@ -139,6 +138,7 @@ export default function Editor() {
</ResizablePanel>
</ResizablePanelGroup>
</div>
+ <Onboarding />
</div>
</EditorProvider>
);
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 47f0020b..6f9e6023 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -33,6 +33,9 @@ export const baseMetaData: Metadata = {
creator: "@opencutapp",
images: [twitterImageUrl],
},
+ pinterest: {
+ richPin: false,
+ },
robots: {
index: true,
follow: true,
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index a1a61d14..4b68791b 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,15 +1,15 @@
"use client";
import {
- Calendar,
- ChevronLeft,
- Loader2,
- MoreHorizontal,
- Plus,
- Search,
- Trash2,
- Video,
- X,
+ Calendar,
+ ChevronLeft,
+ Loader2,
+ MoreHorizontal,
+ Plus,
+ Search,
+ Trash2,
+ Video,
+ X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -21,19 +21,19 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useProjectStore } from "@/stores/project-store";
@@ -41,552 +41,552 @@ import { useTimelineStore } from "@/stores/timeline-store";
import type { TProject } from "@/types/project";
export default function ProjectsPage() {
- const {
- savedProjects,
- isLoading,
- isInitialized,
- deleteProject,
- createNewProject,
- getFilteredAndSortedProjects,
- } = useProjectStore();
- const [thumbnailCache, setThumbnailCache] = useState<
- Record<string, string | null>
- >({});
- const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
- new Set(),
- );
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
- new Set(),
- );
- const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortOption, setSortOption] = useState("createdAt-desc");
- const router = useRouter();
+ const {
+ savedProjects,
+ isLoading,
+ isInitialized,
+ deleteProject,
+ createNewProject,
+ getFilteredAndSortedProjects,
+ } = useProjectStore();
+ const [thumbnailCache, setThumbnailCache] = useState<
+ Record<string, string | null>
+ >({});
+ const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
+ new Set()
+ );
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
+ new Set()
+ );
+ const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortOption, setSortOption] = useState("createdAt-desc");
+ const router = useRouter();
- const getProjectThumbnail = useCallback(
- async (projectId: string): Promise<string | null> => {
- if (thumbnailCache[projectId] !== undefined) {
- return thumbnailCache[projectId];
- }
+ const getProjectThumbnail = useCallback(
+ async (projectId: string): Promise<string | null> => {
+ if (thumbnailCache[projectId] !== undefined) {
+ return thumbnailCache[projectId];
+ }
- setLoadingThumbnails((prev) => new Set(prev).add(projectId));
+ setLoadingThumbnails((prev) => new Set(prev).add(projectId));
- try {
- const thumbnail = await useTimelineStore
- .getState()
- .getProjectThumbnail(projectId);
- setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
- return thumbnail;
- } finally {
- setLoadingThumbnails((prev) => {
- const newSet = new Set(prev);
- newSet.delete(projectId);
- return newSet;
- });
- }
- },
- [thumbnailCache],
- );
+ try {
+ const thumbnail = await useTimelineStore
+ .getState()
+ .getProjectThumbnail(projectId);
+ setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
+ return thumbnail;
+ } finally {
+ setLoadingThumbnails((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(projectId);
+ return newSet;
+ });
+ }
+ },
+ [thumbnailCache]
+ );
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
+ const handleCreateProject = async () => {
+ const projectId = await createNewProject("New Project");
+ console.log("projectId", projectId);
+ router.push(`/editor/${projectId}`);
+ };
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
+ const handleSelectProject = (projectId: string, checked: boolean) => {
+ const newSelected = new Set(selectedProjects);
+ if (checked) {
+ newSelected.add(projectId);
+ } else {
+ newSelected.delete(projectId);
+ }
+ setSelectedProjects(newSelected);
+ };
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
+ } else {
+ setSelectedProjects(new Set());
+ }
+ };
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
+ const handleCancelSelection = () => {
+ setIsSelectionMode(false);
+ setSelectedProjects(new Set());
+ };
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
+ const handleBulkDelete = async () => {
+ await Promise.all(
+ Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
+ );
+ setSelectedProjects(new Set());
+ setIsSelectionMode(false);
+ setIsBulkDeleteDialogOpen(false);
+ };
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
+ const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
+ const allSelected =
+ sortedProjects.length > 0 &&
+ selectedProjects.size === sortedProjects.length;
+ const someSelected =
+ 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">
- <Link
- href="/"
- className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
- >
- <ChevronLeft className="!size-5 shrink-0" />
- <span className="text-sm font-medium">Back</span>
- </Link>
- <div className="block md:hidden">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCancelSelection}
- >
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <CreateButton onClick={handleCreateProject} />
- )}
- </div>
- </div>
- <main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
- <div className="mb-8 flex items-center justify-between">
- <div className="flex flex-col gap-3">
- <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
- Your Projects
- </h1>
- <p className="text-muted-foreground">
- {savedProjects.length}{" "}
- {savedProjects.length === 1 ? "project" : "projects"}
- {isSelectionMode && selectedProjects.size > 0 && (
- <span className="ml-2 text-primary">
- • {selectedProjects.size} selected
- </span>
- )}
- </p>
- </div>
- <div className="hidden md:block">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={handleCancelSelection}>
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete Selected ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsSelectionMode(true)}
- disabled={savedProjects.length === 0}
- >
- Select Projects
- </Button>
- <CreateButton onClick={handleCreateProject} />
- </div>
- )}
- </div>
- </div>
+ return (
+ <div className="min-h-screen bg-background">
+ <div className="pt-6 px-6 flex items-center justify-between w-full h-16">
+ <Link
+ href="/"
+ className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
+ >
+ <ChevronLeft className="!size-5 shrink-0" />
+ <span className="text-sm font-medium">Back</span>
+ </Link>
+ <div className="block md:hidden">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCancelSelection}
+ >
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <CreateButton onClick={handleCreateProject} />
+ )}
+ </div>
+ </div>
+ <main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
+ <div className="mb-8 flex items-center justify-between">
+ <div className="flex flex-col gap-3">
+ <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
+ Your Projects
+ </h1>
+ <p className="text-muted-foreground">
+ {savedProjects.length}{" "}
+ {savedProjects.length === 1 ? "project" : "projects"}
+ {isSelectionMode && selectedProjects.size > 0 && (
+ <span className="ml-2 text-primary">
+ • {selectedProjects.size} selected
+ </span>
+ )}
+ </p>
+ </div>
+ <div className="hidden md:block">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={handleCancelSelection}>
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete Selected ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setIsSelectionMode(true)}
+ disabled={savedProjects.length === 0}
+ >
+ Select Projects
+ </Button>
+ <CreateButton onClick={handleCreateProject} />
+ </div>
+ )}
+ </div>
+ </div>
- <div className="mb-4 flex items-center justify-between gap-4">
- <div className="flex-1 max-w-72">
- <Input
- placeholder="Search projects..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- <Select value={sortOption} onValueChange={setSortOption}>
- <SelectTrigger className="w-[180px]">
- <SelectValue placeholder="Sort by" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
- <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
- <SelectItem value="name-asc">Name (A-Z)</SelectItem>
- <SelectItem value="name-desc">Name (Z-A)</SelectItem>
- </SelectContent>
- </Select>
- </div>
+ <div className="mb-4 flex items-center justify-between gap-4">
+ <div className="flex-1 max-w-72">
+ <Input
+ placeholder="Search projects..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ <Select value={sortOption} onValueChange={setSortOption}>
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Sort by" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
+ <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
+ <SelectItem value="name-asc">Name (A-Z)</SelectItem>
+ <SelectItem value="name-desc">Name (Z-A)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
- {isSelectionMode && sortedProjects.length > 0 && (
- <button
- type="button"
- onClick={() => handleSelectAll(!allSelected)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- handleSelectAll(!allSelected);
- }
- }}
- className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
- 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">
- ({selectedProjects.size} of {sortedProjects.length} selected)
- </span>
- </button>
- )}
+ {isSelectionMode && sortedProjects.length > 0 && (
+ <button
+ type="button"
+ onClick={() => handleSelectAll(!allSelected)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelectAll(!allSelected);
+ }
+ }}
+ className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
+ 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">
+ ({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">
- {Array.from({ length: 8 }, (_, index) => (
- <div
- key={`skeleton-${index}-${Date.now()}`}
- className="overflow-hidden bg-background 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" />
- <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" />
- </div>
- </div>
- </div>
- ))}
- </div>
- ) : savedProjects.length === 0 ? (
- <NoProjects onCreateProject={handleCreateProject} />
- ) : sortedProjects.length === 0 ? (
- <NoResults
- searchQuery={searchQuery}
- onClearSearch={() => setSearchQuery("")}
- />
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {sortedProjects.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- isSelectionMode={isSelectionMode}
- isSelected={selectedProjects.has(project.id)}
- onSelect={handleSelectProject}
- getProjectThumbnail={getProjectThumbnail}
- />
- ))}
- </div>
- )}
- </main>
+ {isLoading || !isInitialized ? (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {Array.from({ length: 8 }, (_, index) => (
+ <div
+ key={`skeleton-${index}-${Date.now()}`}
+ className="overflow-hidden bg-background 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" />
+ <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" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : savedProjects.length === 0 ? (
+ <NoProjects onCreateProject={handleCreateProject} />
+ ) : sortedProjects.length === 0 ? (
+ <NoResults
+ searchQuery={searchQuery}
+ onClearSearch={() => setSearchQuery("")}
+ />
+ ) : (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {sortedProjects.map((project) => (
+ <ProjectCard
+ key={project.id}
+ project={project}
+ isSelectionMode={isSelectionMode}
+ isSelected={selectedProjects.has(project.id)}
+ onSelect={handleSelectProject}
+ getProjectThumbnail={getProjectThumbnail}
+ />
+ ))}
+ </div>
+ )}
+ </main>
- <DeleteProjectDialog
- isOpen={isBulkDeleteDialogOpen}
- onOpenChange={setIsBulkDeleteDialogOpen}
- onConfirm={handleBulkDelete}
- />
- </div>
- );
+ <DeleteProjectDialog
+ isOpen={isBulkDeleteDialogOpen}
+ onOpenChange={setIsBulkDeleteDialogOpen}
+ onConfirm={handleBulkDelete}
+ />
+ </div>
+ );
}
interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
- getProjectThumbnail: (projectId: string) => Promise<string | null>;
+ project: TProject;
+ isSelectionMode?: boolean;
+ isSelected?: boolean;
+ onSelect?: (projectId: string, checked: boolean) => void;
+ getProjectThumbnail: (projectId: string) => Promise<string | null>;
}
function ProjectCard({
- project,
- isSelectionMode = false,
- isSelected = false,
- onSelect,
- getProjectThumbnail,
+ project,
+ isSelectionMode = false,
+ isSelected = false,
+ onSelect,
+ getProjectThumbnail,
}: ProjectCardProps) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
- const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
+ const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
+ const { deleteProject, renameProject, duplicateProject } = useProjectStore();
- useEffect(() => {
- const loadThumbnail = async () => {
- setIsLoadingThumbnail(true);
- try {
- const thumbnail = await getProjectThumbnail(project.id);
- setDynamicThumbnail(thumbnail);
- } finally {
- setIsLoadingThumbnail(false);
- }
- };
- loadThumbnail();
- }, [project.id, getProjectThumbnail]);
+ useEffect(() => {
+ const loadThumbnail = async () => {
+ setIsLoadingThumbnail(true);
+ try {
+ const thumbnail = await getProjectThumbnail(project.id);
+ setDynamicThumbnail(thumbnail);
+ } finally {
+ setIsLoadingThumbnail(false);
+ }
+ };
+ loadThumbnail();
+ }, [project.id, getProjectThumbnail]);
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
+ const formatDate = (date: Date): string => {
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
- const handleDeleteProject = async () => {
- await deleteProject(project.id);
- setIsDropdownOpen(false);
- };
+ const handleDeleteProject = async () => {
+ await deleteProject(project.id);
+ setIsDropdownOpen(false);
+ };
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
+ const handleRenameProject = async (newName: string) => {
+ await renameProject(project.id, newName);
+ setIsRenameDialogOpen(false);
+ };
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
+ const handleDuplicateProject = async () => {
+ setIsDropdownOpen(false);
+ await duplicateProject(project.id);
+ };
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleCardClick = (e: React.MouseEvent) => {
+ if (isSelectionMode) {
+ e.preventDefault();
+ onSelect?.(project.id, !isSelected);
+ }
+ };
- const handleCardKeyDown = (e: React.KeyboardEvent) => {
- if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleCardKeyDown = (e: React.KeyboardEvent) => {
+ if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ onSelect?.(project.id, !isSelected);
+ }
+ };
- const cardContent = (
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted 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 bg-background/80 backdrop-blur-sm border flex items-center justify-center">
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
- </div>
- </div>
- )}
+ const cardContent = (
+ <Card
+ className={`overflow-hidden bg-background border-none p-0 transition-all ${
+ isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
+ }`}
+ >
+ <div
+ className={`relative aspect-square bg-muted 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 bg-background/80 backdrop-blur-sm border flex items-center justify-center">
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) =>
+ onSelect?.(project.id, checked as boolean)
+ }
+ onClick={(e) => e.stopPropagation()}
+ className="w-4 h-4"
+ />
+ </div>
+ </div>
+ )}
- <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>
- ) : dynamicThumbnail ? (
- <Image
- src={dynamicThumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
- </div>
- )}
- </div>
- </div>
+ <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>
+ ) : dynamicThumbnail ? (
+ <Image
+ src={dynamicThumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="w-full h-full bg-muted/50 flex items-center justify-center">
+ <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
+ </div>
+ )}
+ </div>
+ </div>
- <CardContent className="px-0 pt-5 flex flex-col gap-1">
- <div className="flex items-start justify-between">
- <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
- {project.name}
- </h3>
- {!isSelectionMode && (
- <DropdownMenu
- open={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- >
- <DropdownMenuTrigger asChild>
- <Button
- variant="text"
- size="sm"
- className={`size-6 p-0 transition-all shrink-0 ml-2 ${
- isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- onClick={(e) => e.preventDefault()}
- >
- <MoreHorizontal />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent
- align="end"
- onCloseAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- variant="destructive"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
+ <CardContent className="px-0 pt-5 flex flex-col gap-1">
+ <div className="flex items-start justify-between">
+ <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
+ {project.name}
+ </h3>
+ {!isSelectionMode && (
+ <DropdownMenu
+ open={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ >
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className={`size-6 p-0 transition-all shrink-0 ml-2 ${
+ isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ onClick={(e) => e.preventDefault()}
+ >
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicateProject();
+ }}
+ >
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsDeleteDialogOpen(true);
+ }}
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ </div>
- <div className="space-y-1">
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
- <Calendar className="!size-4" />
- <span>Created {formatDate(project.createdAt)}</span>
- </div>
- </div>
- </CardContent>
- </Card>
- );
+ <div className="space-y-1">
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
+ <Calendar className="!size-4" />
+ <span>Created {formatDate(project.createdAt)}</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
- return (
- <>
- {isSelectionMode ? (
- <button
- type="button"
- onClick={handleCardClick}
- onKeyDown={handleCardKeyDown}
- className="block group cursor-pointer w-full text-left"
- >
- {cardContent}
- </button>
- ) : (
- <Link href={`/editor/${project.id}`} className="block group">
- {cardContent}
- </Link>
- )}
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- onConfirm={handleDeleteProject}
- />
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- onConfirm={handleRenameProject}
- projectName={project.name}
- />
- </>
- );
+ return (
+ <>
+ {isSelectionMode ? (
+ <button
+ type="button"
+ onClick={handleCardClick}
+ onKeyDown={handleCardKeyDown}
+ className="block group cursor-pointer w-full text-left"
+ >
+ {cardContent}
+ </button>
+ ) : (
+ <Link href={`/editor/${project.id}`} className="block group">
+ {cardContent}
+ </Link>
+ )}
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ onConfirm={handleDeleteProject}
+ />
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleRenameProject}
+ projectName={project.name}
+ />
+ </>
+ );
}
function CreateButton({ onClick }: { onClick?: () => void }) {
- return (
- <Button className="flex" onClick={onClick}>
- <Plus className="!size-4" />
- <span className="text-sm font-medium">New project</span>
- </Button>
- );
+ return (
+ <Button className="flex" onClick={onClick}>
+ <Plus className="!size-4" />
+ <span className="text-sm font-medium">New project</span>
+ </Button>
+ );
}
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
- return (
- <div className="flex flex-col items-center justify-center py-16 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Video className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No projects yet</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Start creating your first video project. Import media, edit, and export
- professional videos.
- </p>
- <Button size="lg" className="gap-2" onClick={onCreateProject}>
- <Plus className="h-4 w-4" />
- Create Your First Project
- </Button>
- </div>
- );
+ return (
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Video className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">No projects yet</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Start creating your first video project. Import media, edit, and export
+ professional videos.
+ </p>
+ <Button size="lg" className="gap-2" onClick={onCreateProject}>
+ <Plus className="h-4 w-4" />
+ Create Your First Project
+ </Button>
+ </div>
+ );
}
function NoResults({
- searchQuery,
- onClearSearch,
+ searchQuery,
+ onClearSearch,
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ searchQuery: string;
+ onClearSearch: () => 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">
- <Search className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No results found</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- <Button onClick={onClearSearch} variant="outline">
- Clear Search
- </Button>
- </div>
- );
+ 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>
+ <h3 className="text-lg font-medium mb-2">No results found</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ <Button onClick={onClearSearch} variant="outline">
+ Clear Search
+ </Button>
+ </div>
+ );
}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
index 9f7322c7..90ac84f0 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -4,15 +4,14 @@ import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
+import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
+ audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx
new file mode 100644
index 00000000..7ddf465c
--- /dev/null
+++ b/apps/web/src/components/editor/media-panel/views/audio.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function AudioView() {
+ const [search, setSearch] = useState("");
+ return (
+ <div className="h-full flex flex-col gap-2 p-4">
+ <Input
+ placeholder="Search songs and artists"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ <div className="flex flex-col gap-2"></div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index 631fe39a..0189720f 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -208,10 +208,10 @@ export function MediaView() {
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
+ <SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
- <SelectContent className="">
+ <SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
@@ -221,7 +221,7 @@ export function MediaView() {
<Input
type="text"
placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
+ className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -230,7 +230,7 @@ export function MediaView() {
size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 2ecee391..2cba1374 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -16,8 +16,8 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
-import { Play, Pause, Expand } from "lucide-react";
-import { useState, useRef, useEffect } from "react";
+import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
+import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { FONT_CLASS_MAP } from "@/lib/font-config";
@@ -31,9 +31,9 @@ interface ActiveElement {
}
export function PreviewPanel() {
- const { tracks } = useTimelineStore();
+ const { tracks, getTotalDuration } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const { currentTime } = usePlaybackStore();
+ const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore();
const { canvasSize } = useEditorStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -41,52 +41,51 @@ export function PreviewPanel() {
width: 0,
height: 0,
});
+ const [isExpanded, setIsExpanded] = useState(false);
const { activeProject } = useProjectStore();
- // Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
const updatePreviewSize = () => {
if (!containerRef.current) return;
- const container = containerRef.current.getBoundingClientRect();
- const computedStyle = getComputedStyle(containerRef.current);
+ let availableWidth, availableHeight;
- // Get padding values
- const paddingTop = parseFloat(computedStyle.paddingTop);
- const paddingBottom = parseFloat(computedStyle.paddingBottom);
- const paddingLeft = parseFloat(computedStyle.paddingLeft);
- const paddingRight = parseFloat(computedStyle.paddingRight);
-
- // Get gap value (gap-4 = 1rem = 16px)
- const gap = parseFloat(computedStyle.gap) || 16;
-
- // Get toolbar height if it exists
- const toolbar = containerRef.current.querySelector("[data-toolbar]");
- const toolbarHeight = toolbar
- ? toolbar.getBoundingClientRect().height
- : 0;
+ if (isExpanded) {
+ const controlsHeight = 80;
+ const marginSpace = 24;
+ availableWidth = window.innerWidth - marginSpace;
+ availableHeight = window.innerHeight - controlsHeight - marginSpace;
+ } else {
+ const container = containerRef.current.getBoundingClientRect();
+ const computedStyle = getComputedStyle(containerRef.current);
+ const paddingTop = parseFloat(computedStyle.paddingTop);
+ const paddingBottom = parseFloat(computedStyle.paddingBottom);
+ const paddingLeft = parseFloat(computedStyle.paddingLeft);
+ const paddingRight = parseFloat(computedStyle.paddingRight);
+ const gap = parseFloat(computedStyle.gap) || 16;
+ const toolbar = containerRef.current.querySelector("[data-toolbar]");
+ const toolbarHeight = toolbar
+ ? toolbar.getBoundingClientRect().height
+ : 0;
- // Calculate available space after accounting for padding, gap, and toolbar
- const availableWidth = container.width - paddingLeft - paddingRight;
- const availableHeight =
- container.height -
- paddingTop -
- paddingBottom -
- toolbarHeight -
- (toolbarHeight > 0 ? gap : 0);
+ availableWidth = container.width - paddingLeft - paddingRight;
+ availableHeight =
+ container.height -
+ paddingTop -
+ paddingBottom -
+ toolbarHeight -
+ (toolbarHeight > 0 ? gap : 0);
+ }
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
-
let width, height;
if (containerRatio > targetRatio) {
- // Container is wider - constrain by height
- height = availableHeight;
+ height = availableHeight * (isExpanded ? 0.95 : 1);
width = height * targetRatio;
} else {
- // Container is taller - constrain by width
- width = availableWidth;
+ width = availableWidth * (isExpanded ? 0.95 : 1);
height = width / targetRatio;
}
@@ -94,16 +93,47 @@ export function PreviewPanel() {
};
updatePreviewSize();
-
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
+ if (isExpanded) {
+ window.addEventListener("resize", updatePreviewSize);
+ }
+
+ return () => {
+ resizeObserver.disconnect();
+ if (isExpanded) {
+ window.removeEventListener("resize", updatePreviewSize);
+ }
+ };
+ }, [canvasSize.width, canvasSize.height, isExpanded]);
+
+ useEffect(() => {
+ const handleEscapeKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && isExpanded) {
+ setIsExpanded(false);
+ }
+ };
+
+ if (isExpanded) {
+ document.addEventListener("keydown", handleEscapeKey);
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "";
+ }
+
+ return () => {
+ document.removeEventListener("keydown", handleEscapeKey);
+ document.body.style.overflow = "";
+ };
+ }, [isExpanded]);
- return () => resizeObserver.disconnect();
- }, [canvasSize.width, canvasSize.height]);
+ const toggleExpanded = useCallback(() => {
+ setIsExpanded((prev) => !prev);
+ }, []);
- // Get active elements at current time
+ const hasAnyElements = tracks.some((track) => track.elements.length > 0);
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
@@ -116,16 +146,13 @@ export function PreviewPanel() {
if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null;
-
- // Only get media item for media elements
if (element.type === "media") {
mediaItem =
element.mediaId === "test"
- ? null // Test elements don't have a real media item
+ ? null
: mediaItems.find((item) => item.id === element.mediaId) ||
null;
}
-
activeElements.push({ element, track, mediaItem });
}
});
@@ -136,9 +163,6 @@ export function PreviewPanel() {
const activeElements = getActiveElements();
- // Check if there are any elements in the timeline at all
- const hasAnyElements = tracks.some((track) => track.elements.length > 0);
-
// Get media elements for blur background (video/image only)
const getBlurBackgroundElements = (): ActiveElement[] => {
return activeElements.filter(
@@ -338,58 +362,334 @@ export function PreviewPanel() {
};
return (
- <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
- <div
- ref={containerRef}
- className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
- >
- <div className="flex-1"></div>
- {hasAnyElements ? (
- <div
- ref={previewRef}
- className="relative overflow-hidden border"
- style={{
- width: previewDimensions.width,
- height: previewDimensions.height,
- backgroundColor:
- activeProject?.backgroundType === "blur"
- ? "transparent"
- : activeProject?.backgroundColor || "#000000",
- }}
- >
- {renderBlurBackground()}
- {activeElements.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No elements at current time
- </div>
- ) : (
- activeElements.map((elementData, index) =>
- renderElement(elementData, index)
- )
- )}
- {/* Show message when blur is selected but no media available */}
- {activeProject?.backgroundType === "blur" &&
- blurBackgroundElements.length === 0 &&
- activeElements.length > 0 && (
- <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
- Add a video or image to use blur background
+ <>
+ <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
+ <div
+ ref={containerRef}
+ className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
+ >
+ <div className="flex-1"></div>
+ {hasAnyElements ? (
+ <div
+ ref={previewRef}
+ className="relative overflow-hidden border"
+ style={{
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "transparent"
+ : activeProject?.backgroundColor || "#000000",
+ }}
+ >
+ {renderBlurBackground()}
+ {activeElements.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
+ No elements at current time
</div>
+ ) : (
+ activeElements.map((elementData, index) =>
+ renderElement(elementData, index)
+ )
)}
- </div>
- ) : null}
+ {activeProject?.backgroundType === "blur" &&
+ blurBackgroundElements.length === 0 &&
+ activeElements.length > 0 && (
+ <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
+ Add a video or image to use blur background
+ </div>
+ )}
+ </div>
+ ) : null}
+
+ <div className="flex-1"></div>
+
+ <PreviewToolbar
+ hasAnyElements={hasAnyElements}
+ onToggleExpanded={toggleExpanded}
+ isExpanded={isExpanded}
+ currentTime={currentTime}
+ setCurrentTime={setCurrentTime}
+ toggle={toggle}
+ getTotalDuration={getTotalDuration}
+ />
+ </div>
+ </div>
+
+ {isExpanded && (
+ <FullscreenPreview
+ previewDimensions={previewDimensions}
+ activeProject={activeProject}
+ renderBlurBackground={renderBlurBackground}
+ activeElements={activeElements}
+ renderElement={renderElement}
+ blurBackgroundElements={blurBackgroundElements}
+ hasAnyElements={hasAnyElements}
+ toggleExpanded={toggleExpanded}
+ currentTime={currentTime}
+ setCurrentTime={setCurrentTime}
+ toggle={toggle}
+ getTotalDuration={getTotalDuration}
+ />
+ )}
+ </>
+ );
+}
+
+function FullscreenToolbar({
+ hasAnyElements,
+ onToggleExpanded,
+ currentTime,
+ setCurrentTime,
+ toggle,
+ getTotalDuration,
+}: {
+ hasAnyElements: boolean;
+ onToggleExpanded: () => void;
+ currentTime: number;
+ setCurrentTime: (time: number) => void;
+ toggle: () => void;
+ getTotalDuration: () => number;
+}) {
+ const { isPlaying } = usePlaybackStore();
+ const { activeProject } = useProjectStore();
+ const [isDragging, setIsDragging] = useState(false);
+
+ const totalDuration = getTotalDuration();
+ const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
+
+ const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!hasAnyElements) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, clickX / rect.width));
+ const newTime = percentage * totalDuration;
+ setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
+ };
+
+ const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!hasAnyElements) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ setIsDragging(true);
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ moveEvent.preventDefault();
+ const dragX = moveEvent.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, dragX / rect.width));
+ const newTime = percentage * totalDuration;
+ setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.body.style.userSelect = "";
+ };
+
+ document.body.style.userSelect = "none";
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ handleMouseMove(e.nativeEvent);
+ };
+
+ const skipBackward = () => {
+ const newTime = Math.max(0, currentTime - 1);
+ setCurrentTime(newTime);
+ };
+
+ const skipForward = () => {
+ const newTime = Math.min(totalDuration, currentTime + 1);
+ setCurrentTime(newTime);
+ };
- <div className="flex-1"></div>
+ return (
+ <div
+ data-toolbar
+ className="flex items-center gap-2 p-1 pt-2 w-full text-white"
+ >
+ <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
+ <span className="text-primary">
+ {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
+ </span>
+ <span className="opacity-50">/</span>
+ <span>
+ {formatTimeCode(
+ totalDuration,
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
+ </div>
- <PreviewToolbar hasAnyElements={hasAnyElements} />
+ <div className="flex items-center gap-1">
+ <Button
+ variant="text"
+ size="icon"
+ onClick={skipBackward}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ title="Skip backward 1s"
+ >
+ <SkipBack className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ >
+ {isPlaying ? (
+ <Pause className="h-3 w-3" />
+ ) : (
+ <Play className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={skipForward}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ title="Skip forward 1s"
+ >
+ <SkipForward className="h-3 w-3" />
+ </Button>
</div>
+
+ <div className="flex-1 flex items-center gap-2">
+ <div
+ className={cn(
+ "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20",
+ !hasAnyElements && "opacity-50 cursor-not-allowed"
+ )}
+ onClick={hasAnyElements ? handleTimelineClick : undefined}
+ onMouseDown={hasAnyElements ? handleTimelineDrag : undefined}
+ style={{ userSelect: "none" }}
+ >
+ <div
+ className={cn(
+ "absolute top-0 left-0 h-full rounded-full bg-white",
+ !isDragging && "duration-100"
+ )}
+ style={{ width: `${progress}%` }}
+ />
+ <div
+ className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20"
+ style={{ left: `${progress}%` }}
+ />
+ </div>
+ </div>
+
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 text-white/80 hover:text-white"
+ onClick={onToggleExpanded}
+ title="Exit fullscreen (Esc)"
+ >
+ <Expand className="!size-4" />
+ </Button>
</div>
);
}
-function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
- const { isPlaying, toggle, currentTime } = usePlaybackStore();
+function FullscreenPreview({
+ previewDimensions,
+ activeProject,
+ renderBlurBackground,
+ activeElements,
+ renderElement,
+ blurBackgroundElements,
+ hasAnyElements,
+ toggleExpanded,
+ currentTime,
+ setCurrentTime,
+ toggle,
+ getTotalDuration,
+}: {
+ previewDimensions: { width: number; height: number };
+ activeProject: any;
+ renderBlurBackground: () => React.ReactNode;
+ activeElements: ActiveElement[];
+ renderElement: (elementData: ActiveElement, index: number) => React.ReactNode;
+ blurBackgroundElements: ActiveElement[];
+ hasAnyElements: boolean;
+ toggleExpanded: () => void;
+ currentTime: number;
+ setCurrentTime: (time: number) => void;
+ toggle: () => void;
+ getTotalDuration: () => number;
+}) {
+ return (
+ <div className="fixed inset-0 z-[9999] flex flex-col">
+ <div className="flex-1 flex items-center justify-center bg-background">
+ <div
+ className="relative overflow-hidden border border-border m-3"
+ style={{
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "#1a1a1a"
+ : activeProject?.backgroundColor || "#1a1a1a",
+ }}
+ >
+ {renderBlurBackground()}
+ {activeElements.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center text-white/60">
+ No elements at current time
+ </div>
+ ) : (
+ activeElements.map((elementData, index) =>
+ renderElement(elementData, index)
+ )
+ )}
+ {activeProject?.backgroundType === "blur" &&
+ blurBackgroundElements.length === 0 &&
+ activeElements.length > 0 && (
+ <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
+ Add a video or image to use blur background
+ </div>
+ )}
+ </div>
+ </div>
+ <div className="p-4 bg-black">
+ <FullscreenToolbar
+ hasAnyElements={hasAnyElements}
+ onToggleExpanded={toggleExpanded}
+ currentTime={currentTime}
+ setCurrentTime={setCurrentTime}
+ toggle={toggle}
+ getTotalDuration={getTotalDuration}
+ />
+ </div>
+ </div>
+ );
+}
+
+function PreviewToolbar({
+ hasAnyElements,
+ onToggleExpanded,
+ isExpanded,
+ currentTime,
+ setCurrentTime,
+ toggle,
+ getTotalDuration,
+}: {
+ hasAnyElements: boolean;
+ onToggleExpanded: () => void;
+ isExpanded: boolean;
+ currentTime: number;
+ setCurrentTime: (time: number) => void;
+ toggle: () => void;
+ getTotalDuration: () => number;
+}) {
+ const { isPlaying } = usePlaybackStore();
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
- const { getTotalDuration } = useTimelineStore();
const { activeProject } = useProjectStore();
const {
currentPreset,
@@ -408,6 +708,21 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
setCanvasSizeToOriginal(aspectRatio);
};
+ if (isExpanded) {
+ return (
+ <FullscreenToolbar
+ {...{
+ hasAnyElements,
+ onToggleExpanded,
+ currentTime,
+ setCurrentTime,
+ toggle,
+ getTotalDuration,
+ }}
+ />
+ );
+ }
+
return (
<div
data-toolbar
@@ -488,6 +803,8 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
variant="text"
size="icon"
className="!size-4 text-muted-foreground"
+ onClick={onToggleExpanded}
+ title="Enter fullscreen"
>
<Expand className="!size-4" />
</Button>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 9e68fd4d..2360eb8a 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,7 +1,7 @@
"use client";
-import { ScrollArea } from "../ui/scroll-area";
-import { Button } from "../ui/button";
+import { ScrollArea } from "../../ui/scroll-area";
+import { Button } from "../../ui/button";
import {
Scissors,
ArrowLeftToLine,
@@ -15,22 +15,23 @@ import {
Video,
Music,
TypeIcon,
- Lock,
- LockOpen,
+ Magnet,
Link,
+ ZoomIn,
+ ZoomOut,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
-} from "../ui/tooltip";
+} from "../../ui/tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
-} from "../ui/context-menu";
+} from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
@@ -44,9 +45,9 @@ import {
TimelinePlayhead,
useTimelinePlayheadRuler,
} from "./timeline-playhead";
-import { SelectionBox } from "./selection-box";
+import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
-import { SnapIndicator } from "./snap-indicator";
+import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import {
@@ -56,6 +57,7 @@ import {
TIMELINE_CONSTANTS,
snapTimeToFrame,
} from "@/constants/timeline-constants";
+import { Slider } from "@/components/ui/slider";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -64,23 +66,11 @@ export function Timeline() {
const {
tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
getTotalDuration,
- selectedElements,
clearSelectedElements,
+ snappingEnabled,
setSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
toggleTrackMute,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
dragState,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
@@ -288,7 +278,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@@ -430,142 +420,6 @@ export function Timeline() {
onDrop: handleDrop,
};
- // Action handlers for toolbar
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- const newSelections: { trackId: string; elementId: string }[] = [];
-
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => el.id === elementId);
-
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
-
- // Create element without id (will be generated by store)
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
-
- // We can't predict the new id, so just clear selection for now
- // TODO: addElementToTrack could return the new element id
- }
- });
-
- clearSelectedElements();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- const handleSplitAndKeepLeft = () => {
- 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((c) => c.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);
- };
-
- const handleSplitAndKeepRight = () => {
- 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((c) => c.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);
- };
-
- 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 handleDeleteSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(({ trackId, elementId }) => {
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- removeElementFromTrack(trackId, elementId);
- }
- });
- clearSelectedElements();
- };
-
// --- Scroll synchronization effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
@@ -652,192 +506,7 @@ export function Timeline() {
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
>
- {/* Toolbar */}
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- Add a test clip to try playback
- </TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitSelected}
- >
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSeparateAudio}
- >
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleFreezeSelected}
- >
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDeleteSelected}
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <LockOpen className="h-4 w-4 text-primary" />
- ) : (
- <Lock className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggleRippleEditing}
- >
- <Link
- className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </div>
+ <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} />
{/* Timeline Container */}
<div
@@ -881,7 +550,13 @@ export function Timeline() {
{/* Timeline Ruler */}
<div
className="flex-1 relative overflow-hidden h-4"
- onWheel={handleWheel}
+ onWheel={(e) => {
+ // Check if this is horizontal scrolling - if so, don't handle it here
+ if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ return; // Let ScrollArea handle horizontal scrolling
+ }
+ handleWheel(e);
+ }}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
@@ -923,21 +598,19 @@ export function Timeline() {
return (
<div
key={i}
- className={`absolute top-0 bottom-0 ${
- isMainMarker
+ className={`absolute top-0 bottom-0 ${isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
- }`}
+ }`}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
- className={`absolute top-1 left-1 text-[0.6rem] ${
- isMainMarker
+ className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
- }`}
+ }`}
>
{(() => {
const formatTime = (seconds: number) => {
@@ -1002,7 +675,13 @@ export function Timeline() {
{/* Timeline Tracks Content */}
<div
className="flex-1 relative overflow-hidden"
- onWheel={handleWheel}
+ onWheel={(e) => {
+ // Check if this is horizontal scrolling - if so, don't handle it here
+ if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ return; // Let ScrollArea handle horizontal scrolling
+ }
+ handleWheel(e);
+ }}
onMouseDown={(e) => {
handleTimelineMouseDown(e);
handleSelectionMouseDown(e);
@@ -1016,7 +695,12 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <ScrollArea
+ className="w-full h-full"
+ ref={tracksScrollRef}
+ type="scroll"
+ showHorizontalScrollbar
+ >
<div
className="relative flex-1"
style={{
@@ -1093,3 +777,340 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
</>
);
}
+
+function TimelineToolbar({
+ zoomLevel,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ setZoomLevel: (zoom: number) => void;
+}) {
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ removeElementFromTrack,
+ removeElementFromTrackWithRipple,
+ selectedElements,
+ clearSelectedElements,
+ splitElement,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ } = useTimelineStore();
+ const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
+
+ // Action handlers
+ const handleSplitSelected = () => {
+ if (selectedElements.length === 0) return;
+ let splitCount = 0;
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (element && track) {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newElementId = splitElement(trackId, elementId, currentTime);
+ if (newElementId) splitCount++;
+ }
+ }
+ });
+ if (splitCount === 0) {
+ toast.error("Playhead must be within selected elements to split");
+ }
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedElements.length === 0) return;
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((el) => el.id === elementId);
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
+ clearSelectedElements();
+ };
+
+ const handleFreezeSelected = () => {
+ toast.info("Freeze frame functionality coming soon!");
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ 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((c) => c.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);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ 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((c) => c.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);
+ };
+
+ 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 handleDeleteSelected = () => {
+ if (selectedElements.length === 0) return;
+ selectedElements.forEach(({ trackId, elementId }) => {
+ if (rippleEditingEnabled) {
+ removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ removeElementFromTrack(trackId, elementId);
+ }
+ });
+ clearSelectedElements();
+ };
+
+ // Zoom handlers
+ const handleZoomIn = () => {
+ setZoomLevel(Math.min(4, zoomLevel + 0.25));
+ };
+
+ const handleZoomOut = () => {
+ setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
+ };
+
+ const handleZoomSliderChange = (values: number[]) => {
+ setZoomLevel(values[0]);
+ };
+ return (
+ <div className="border-b flex items-center justify-between px-2 py-1">
+ <div className="flex items-center gap-1 w-full">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
+ mediaId: "test",
+ name: "Test Clip",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleSnapping}>
+ {snappingEnabled ? (
+ <Magnet className="h-4 w-4 text-primary" />
+ ) : (
+ <Magnet className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleRippleEditing}>
+ <Link
+ className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {rippleEditingEnabled
+ ? "Disable Ripple Editing"
+ : "Enable Ripple Editing"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ <div className="flex items-center gap-1">
+ <Button variant="text" size="icon" onClick={handleZoomOut}>
+ <ZoomOut className="h-4 w-4" />
+ </Button>
+ <Slider
+ className="w-24"
+ value={[zoomLevel]}
+ onValueChange={handleZoomSliderChange}
+ min={0.25}
+ max={4}
+ step={0.25}
+ />
+ <Button variant="text" size="icon" onClick={handleZoomIn}>
+ <ZoomIn className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index eb9f1c01..8aadde82 100644
--- a/apps/web/src/components/editor/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { Button } from "../ui/button";
+import { Button } from "../../ui/button";
import {
MoreVertical,
Scissors,
@@ -17,7 +17,7 @@ import {
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
-import AudioWaveform from "./audio-waveform";
+import AudioWaveform from "../audio-waveform";
import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
@@ -35,14 +35,14 @@ import {
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
-} from "../ui/dropdown-menu";
+} from "../../ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
-} from "../ui/context-menu";
+} from "../../ui/context-menu";
export function TimelineElement({
element,
@@ -239,15 +239,15 @@ export function TimelineElement({
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight - VIDEO_TILE_PADDING;
+ const tileHeight = trackHeight - 8; // Match image padding
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 h-full relative overflow-hidden">
+ <div className="w-full h-full flex items-center justify-center">
+ <div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled thumbnails */}
<div
- className="absolute inset-0"
+ className="absolute top-3 bottom-3 left-0 right-0"
style={{
backgroundImage: mediaItem.thumbnailUrl
? `url(${mediaItem.thumbnailUrl})`
@@ -261,7 +261,7 @@ export function TimelineElement({
/>
{/* Overlay with vertical borders */}
<div
- className="absolute inset-0 pointer-events-none"
+ className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
@@ -274,15 +274,6 @@ export function TimelineElement({
}}
/>
</div>
- {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
- <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
- {element.name}
- </div>
- ) : (
- <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
- {element.name}
- </span>
- )}
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index 7a357067..7a357067 100644
--- a/apps/web/src/components/editor/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 82ca0b98..82ca0b98 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 5b7f2303..a7aa724f 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -3,7 +3,8 @@
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
-import { RiDiscordFill, RiGithubLine, RiTwitterXLine } from "react-icons/ri";
+import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
+import { FaGithub } from "react-icons/fa6";
import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
@@ -49,7 +50,7 @@ export function Footer() {
target="_blank"
rel="noopener noreferrer"
>
- <RiGithubLine className="h-5 w-5" />
+ <FaGithub className="h-5 w-5" />
</Link>
<Link
href="https://x.com/OpenCutApp"
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index bfe314d6..7d11e68a 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -4,28 +4,9 @@ import Link from "next/link";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
-import { useSession } from "@opencut/auth/client";
-import { getStars } from "@/lib/fetch-github-stars";
-import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() {
- const { data: session } = useSession();
- const [star, setStar] = useState<string>("");
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
@@ -35,26 +16,22 @@ export function Header() {
const rightContent = (
<nav className="flex items-center gap-3">
+ <Link href="/blog">
+ <Button variant="text" className="text-sm p-0">
+ Blog
+ </Button>
+ </Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
- {process.env.NODE_ENV === "development" ? (
- <Link href="/projects">
- <Button size="sm" className="text-sm ml-4">
- Projects
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- ) : (
- <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
- <Button size="sm" className="text-sm ml-4">
- GitHub {star}+
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- )}
+ <Link href="/projects">
+ <Button size="sm" className="text-sm ml-4">
+ Projects
+ <ArrowRight className="h-4 w-4" />
+ </Button>
+ </Link>
</nav>
);
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index d5fe38d1..dba958ac 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -2,104 +2,15 @@
import { motion } from "motion/react";
import { Button } from "../ui/button";
-import { Input } from "../ui/input";
import { SponsorButton } from "../ui/sponsor-button";
import { VercelIcon } from "../icons";
import { ArrowRight } from "lucide-react";
-import { useState, useEffect } from "react";
-import { toast } from "sonner";
import Image from "next/image";
import { Handlebars } from "./handlebars";
+import Link from "next/link";
export function Hero() {
- const [email, setEmail] = useState("");
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [csrfToken, setCsrfToken] = useState<string | null>(null);
-
- useEffect(() => {
- let isMounted = true;
- fetch("/api/waitlist/token", {
- credentials: "include",
- })
- .then((res) => res.json())
- .then((data) => {
- if (isMounted && data.token) {
- setCsrfToken(data.token);
- }
- })
- .catch((err) => {
- console.error("Failed to fetch CSRF token:", err);
- if (isMounted) {
- toast.error("Security initialization failed", {
- description: "Please refresh the page to continue.",
- });
- }
- });
- }, []);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!email.trim()) {
- toast.error("Email required", {
- description: "Please enter your email address.",
- });
- return;
- }
-
- if (!csrfToken) {
- toast.error("Security error", {
- description: "Please refresh the page and try again.",
- });
- return;
- }
-
- setIsSubmitting(true);
-
- try {
- const response = await fetch("/api/waitlist", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": csrfToken,
- },
- credentials: "include",
- body: JSON.stringify({ email: email.trim() }),
- });
-
- const data = (await response.json()) as { error: string };
-
- if (response.ok) {
- toast.success("Welcome to the waitlist! 🎉", {
- description: "You'll be notified when we launch.",
- });
- setEmail("");
-
- fetch("/api/waitlist/token", { credentials: "include" })
- .then((res) => res.json())
- .then((data) => {
- if (data.token) setCsrfToken(data.token);
- })
- .catch((err) => {
- console.error("Failed to refresh CSRF token:", err);
- });
- } else {
- toast.error("Oops!", {
- description:
- (data as { error: string }).error ||
- "Something went wrong. Please try again.",
- });
- }
- } catch (error) {
- toast.error("Network error", {
- description: "Please check your connection and try again.",
- });
- } finally {
- setIsSubmitting(false);
- }
- };
-
return (
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
<Image
@@ -121,8 +32,8 @@ export function Hero() {
transition={{ delay: 0.6, duration: 0.8 }}
className="mb-8 flex justify-center"
>
- <SponsorButton
- href="https://vercel.com/?utm_source=opencut"
+ <SponsorButton
+ href="https://vercel.com/home?utm_source=opencut"
logo={VercelIcon}
companyName="Vercel"
/>
@@ -148,48 +59,22 @@ export function Hero() {
</motion.p>
<motion.div
- className="mt-12 flex gap-8 justify-center"
+ className="mt-8 flex gap-8 justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
- <form
- onSubmit={handleSubmit}
- className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
- >
- <div className="relative w-full">
- <Input
- type="email"
- placeholder="Enter your email"
- className="h-11 text-base flex-1"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- disabled={isSubmitting || !csrfToken}
- required
- />
- </div>
+ <Link href="/projects">
<Button
type="submit"
size="lg"
- className="px-6 h-11 text-base !bg-foreground"
- disabled={isSubmitting || !csrfToken}
+ className="px-6 h-11 text-base bg-foreground"
>
- <span className="relative z-10">
- {isSubmitting ? "Joining..." : "Join waitlist"}
- </span>
+ Try early beta
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
</Button>
- </form>
+ </Link>
</motion.div>
- <motion.div
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.8, duration: 0.6 }}
- className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
- >
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- <span>50k+ people already joined</span>
- </motion.div>
</motion.div>
</div>
);
diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx
index a2943677..80b5f99c 100644
--- a/apps/web/src/components/onboarding.tsx
+++ b/apps/web/src/components/onboarding.tsx
@@ -1,29 +1,121 @@
"use client";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "./ui/dialog";
+import { Dialog, DialogContent } from "./ui/dialog";
+import { Button } from "./ui/button";
+import { ArrowRightIcon } from "lucide-react";
+import { useState, useEffect } from "react";
+import ReactMarkdown from "react-markdown";
-interface OnboardingProps {
- isOpen: boolean;
- onClose: () => void;
-}
+export function Onboarding() {
+ const [step, setStep] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
+ if (!hasSeenOnboarding) {
+ setIsOpen(true);
+ }
+ }, []);
+
+ const handleNext = () => {
+ setStep(step + 1);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ localStorage.setItem("hasSeenOnboarding", "true");
+ };
+
+ const renderStepContent = () => {
+ switch (step) {
+ case 0:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="Welcome to OpenCut Beta! 🎉" />
+ <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 1:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="⚠️ This is a super early beta!" />
+ <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
+ <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 2:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="🦋 Have fun testing!" />
+ <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
+ </div>
+ <NextButton onClick={handleClose}>Finish</NextButton>
+ </div>
+ );
+ default:
+ return null;
+ }
+ };
-export function Onboarding({ isOpen, onClose }: OnboardingProps) {
return (
- <Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Welcome to Clipture</DialogTitle>
- <DialogDescription>
- Let's get you started with the basics.
- </DialogDescription>
- </DialogHeader>
+ <Dialog open={isOpen} onOpenChange={handleClose}>
+ <DialogContent className="sm:max-w-[425px] !outline-none">
+ {renderStepContent()}
</DialogContent>
</Dialog>
);
}
+
+function Title({ title }: { title: string }) {
+ return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
+}
+
+function Subtitle({ subtitle }: { subtitle: string }) {
+ return <h3 className="text-lg font-medium">{subtitle}</h3>;
+}
+
+function Description({ description }: { description: string }) {
+ return (
+ <div className="text-muted-foreground">
+ <ReactMarkdown
+ components={{
+ p: ({ children }) => <p className="mb-0">{children}</p>,
+ a: ({ href, children }) => (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-foreground hover:text-foreground/80 underline"
+ >
+ {children}
+ </a>
+ ),
+ }}
+ >
+ {description}
+ </ReactMarkdown>
+ </div>
+ );
+}
+
+function NextButton({
+ children,
+ onClick,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+}) {
+ return (
+ <Button onClick={onClick} variant="default" className="w-full">
+ {children}
+ <ArrowRightIcon className="w-4 h-4" />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index ebfd8907..e8decbd7 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
- "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+ "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
onCloseAutoFocus={(e) => {
@@ -55,7 +55,7 @@ const DialogContent = React.forwardRef<
<ScrollArea className="max-h-[85vh]">
<div className="p-6 space-y-4">{children}</div>
</ScrollArea>
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+ <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx
new file mode 100644
index 00000000..c25a5a65
--- /dev/null
+++ b/apps/web/src/components/ui/prose.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils";
+import type React from "react";
+
+type ProseProps = React.HTMLAttributes<HTMLElement> & {
+ as?: "article";
+ html: string;
+};
+
+function Prose({ children, html, className }: ProseProps) {
+ return (
+ <article
+ className={cn(
+ "prose prose-h2:font-semibold max-w-none prose-h1:text-xl prose-a:text-blue-600 prose-p:text-justify dark:prose-invert mx-auto",
+ className
+ )}
+ >
+ {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
+ </article>
+ );
+}
+
+export default Prose;
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
index 6b7c3c53..5c9db727 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -7,17 +7,22 @@ import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
+ type?: "auto" | "always" | "scroll" | "hover";
+ showHorizontalScrollbar?: boolean;
+ }
+>(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
+ type={type}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
+ {showHorizontalScrollbar && <ScrollBar orientation="horizontal" />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx
index 6138cabf..12d81c4a 100644
--- a/apps/web/src/components/ui/separator.tsx
+++ b/apps/web/src/components/ui/separator.tsx
@@ -1,9 +1,9 @@
-"use client";
+"use client"
-import * as React from "react";
-import { Separator as SeparatorPrimitive } from "radix-ui";
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -25,7 +25,7 @@ const Separator = React.forwardRef<
{...props}
/>
)
-);
-Separator.displayName = SeparatorPrimitive.Root.displayName;
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
-export { Separator };
+export { Separator }
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
index 8c76a2e8..5d446d50 100644
--- a/apps/web/src/constants/timeline-constants.ts
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -80,11 +80,11 @@ export const TIMELINE_CONSTANTS = {
// FPS presets for project settings
export const FPS_PRESETS = [
- { value: "24", label: "24 fps (Film)" },
- { value: "25", label: "25 fps (PAL)" },
- { value: "30", label: "30 fps (NTSC)" },
- { value: "60", label: "60 fps (High)" },
- { value: "120", label: "120 fps (Slow-mo)" },
+ { value: "24", label: "24 fps" },
+ { value: "25", label: "25 fps" },
+ { value: "30", label: "30 fps" },
+ { value: "60", label: "60 fps" },
+ { value: "120", label: "120 fps" },
] as const;
// Frame snapping utilities
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts
index e0d9b9eb..4e8f6aef 100644
--- a/apps/web/src/hooks/use-editor-actions.ts
+++ b/apps/web/src/hooks/use-editor-actions.ts
@@ -102,7 +102,6 @@ export function useEditorActions() {
useActionHandler("delete-selected", () => {
if (selectedElements.length === 0) {
- toast.error("No elements selected");
return;
}
selectedElements.forEach(
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts
index f16ca8e2..3afa637b 100644
--- a/apps/web/src/hooks/use-timeline-element-resize.ts
+++ b/apps/web/src/hooks/use-timeline-element-resize.ts
@@ -29,7 +29,12 @@ export function useTimelineElementResize({
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
const { mediaItems } = useMediaStore();
- const { updateElementStartTime } = useTimelineStore();
+ const {
+ updateElementStartTime,
+ updateElementTrim,
+ updateElementDuration,
+ pushHistory,
+ } = useTimelineStore();
// Set up document-level mouse listeners during resize (like proper drag behavior)
useEffect(() => {
@@ -61,6 +66,9 @@ export function useTimelineElementResize({
e.stopPropagation();
e.preventDefault();
+ // Push history once at the start of the resize operation
+ pushHistory();
+
setResizing({
elementId,
side,
@@ -112,13 +120,14 @@ export function useTimelineElementResize({
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = element.startTime + trimDelta;
- onUpdateTrim(
+ updateElementTrim(
track.id,
element.id,
newTrimStart,
- resizing.initialTrimEnd
+ resizing.initialTrimEnd,
+ false
);
- updateElementStartTime(track.id, element.id, newStartTime);
+ updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Trying to extend beyond trimStart = 0
if (canExtendElementDuration()) {
@@ -128,22 +137,29 @@ export function useTimelineElementResize({
const newDuration = element.duration + extensionAmount;
// Keep trimStart at 0 and extend the element
- onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd);
- onUpdateDuration(track.id, element.id, newDuration);
- updateElementStartTime(track.id, element.id, newStartTime);
+ updateElementTrim(
+ track.id,
+ element.id,
+ 0,
+ resizing.initialTrimEnd,
+ false
+ );
+ updateElementDuration(track.id, element.id, newDuration, false);
+ updateElementStartTime(track.id, element.id, newStartTime, false);
} else {
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = element.startTime + trimDelta;
- onUpdateTrim(
+ updateElementTrim(
track.id,
element.id,
newTrimStart,
- resizing.initialTrimEnd
+ resizing.initialTrimEnd,
+ false
);
- updateElementStartTime(track.id, element.id, newStartTime);
+ updateElementStartTime(track.id, element.id, newStartTime, false);
}
}
} else {
@@ -159,27 +175,35 @@ export function useTimelineElementResize({
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
// Update duration first, then trim
- onUpdateDuration(track.id, element.id, newDuration);
- onUpdateTrim(
+ updateElementDuration(track.id, element.id, newDuration, false);
+ updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
- newTrimEnd
+ newTrimEnd,
+ false
);
} else {
// Can't extend - just set trimEnd to 0 (maximum possible extension)
- onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
+ updateElementTrim(
+ track.id,
+ element.id,
+ resizing.initialTrimStart,
+ 0,
+ false
+ );
}
} else {
// Normal trimming within original duration
const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
- onUpdateTrim(
+ updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
- newTrimEnd
+ newTrimEnd,
+ false
);
}
}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
index ce32c9f0..4f5e0af0 100644
--- a/apps/web/src/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/use-timeline-zoom.ts
@@ -24,6 +24,12 @@ export function useTimelineZoom({
const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
+ // For horizontal scrolling (when shift is held or horizontal wheel movement),
+ // let the event bubble up to allow ScrollArea to handle it
+ else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ // Don't prevent default - let ScrollArea handle horizontal scrolling
+ return;
+ }
// Otherwise, allow normal scrolling
}, []);
diff --git a/apps/web/src/lib/blog-query.ts b/apps/web/src/lib/blog-query.ts
new file mode 100644
index 00000000..430f58bf
--- /dev/null
+++ b/apps/web/src/lib/blog-query.ts
@@ -0,0 +1,55 @@
+import type { MarbleAuthorList, MarbleCategoryList, MarblePost, MarblePostList, MarbleTagList } from '@/types/post';
+import { unified } from "unified";
+import rehypeParse from "rehype-parse";
+import rehypeStringify from "rehype-stringify";
+import rehypeSlug from "rehype-slug";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+import rehypeSanitize from "rehype-sanitize";
+
+const url = process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
+const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
+
+async function fetchFromMarble<T>(endpoint: string): Promise<T> {
+ try {
+ const response = await fetch(`${url}/${key}/${endpoint}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`);
+ }
+ return await response.json() as T;
+ } catch (error) {
+ console.error(`Error fetching ${endpoint}:`, error);
+ throw error;
+ }
+ }
+
+ export async function getPosts() {
+ return fetchFromMarble<MarblePostList>('posts');
+ }
+
+ export async function getTags() {
+ return fetchFromMarble<MarbleTagList>('tags');
+ }
+
+ export async function getSinglePost(slug: string) {
+ return fetchFromMarble<MarblePost>(`posts/${slug}`);
+ }
+
+ export async function getCategories() {
+ return fetchFromMarble<MarbleCategoryList>('categories');
+ }
+
+ export async function getAuthors() {
+ return fetchFromMarble<MarbleAuthorList>('authors');
+ }
+
+export async function processHtmlContent(html: string): Promise<string> {
+ const processor = unified()
+ .use(rehypeSanitize)
+ .use(rehypeParse, { fragment: true })
+ .use(rehypeSlug)
+ .use(rehypeAutolinkHeadings, { behavior: "append" })
+ .use(rehypeStringify);
+
+ const file = await processor.process(html);
+ return String(file);
+}
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index b571061c..e2562ef1 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,36 +1,12 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-import { env } from "./env";
-import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
- const protectedPaths = ["/editor", "/projects"];
- const sessionCookie = getSessionCookie(request);
-
- const canAccessProtectedPaths = (request: NextRequest) => {
- if (env.NODE_ENV === "development") {
- return true;
- }
-
- if (sessionCookie) {
- return true;
- }
- return false;
- };
-
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
}
- const path = request.nextUrl.pathname;
-
- if (protectedPaths.includes(path) && !canAccessProtectedPaths(request)) {
- const homeUrl = new URL("/", request.url);
- homeUrl.searchParams.set("redirect", request.url);
- return NextResponse.redirect(homeUrl);
- }
-
return NextResponse.next();
}
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 60c71afd..3e525b86 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -195,6 +195,7 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
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 ?? "";
// Check arrow keys
if (key.startsWith("arrow")) {
@@ -213,7 +214,15 @@ function getPressedKey(ev: KeyboardEvent): string | null {
const isLetter = key.length === 1 && key >= "a" && key <= "z";
if (isLetter) return key;
- // Check if number keys
+ // 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;
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index ca4da916..8a4abc6a 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,1467 +1,1477 @@
-import { toast } from "sonner";
import { create } from "zustand";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { storageService } from "@/lib/storage/storage-service";
-import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
-import { generateUUID } from "@/lib/utils";
import {
- type CreateTimelineElement,
- type DragData,
- ensureMainTrack,
- sortTracksByOrder,
- type TextElement,
- type TimelineElement,
- type TimelineTrack,
- type TrackType,
- validateElementTrackCompatibility,
+ TrackType,
+ TimelineElement,
+ CreateTimelineElement,
+ TimelineTrack,
+ TextElement,
+ DragData,
+ sortTracksByOrder,
+ ensureMainTrack,
+ validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import {
- getMediaAspectRatio,
- type MediaItem,
- type MediaType,
- useMediaStore,
+ useMediaStore,
+ getMediaAspectRatio,
+ type MediaItem,
} from "./media-store";
+import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
+import { generateUUID } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { toast } from "sonner";
+import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
- originalName: string,
- suffix: string,
+ originalName: string,
+ suffix: string
): string => {
- // Remove existing suffixes to prevent accumulation
- const baseName = originalName
- .replace(/ \(left\)$/, "")
- .replace(/ \(right\)$/, "")
- .replace(/ \(audio\)$/, "")
- .replace(/ \(split \d+\)$/, "");
+ // Remove existing suffixes to prevent accumulation
+ const baseName = originalName
+ .replace(/ \(left\)$/, "")
+ .replace(/ \(right\)$/, "")
+ .replace(/ \(audio\)$/, "")
+ .replace(/ \(split \d+\)$/, "");
- return `${baseName} (${suffix})`;
+ return `${baseName} (${suffix})`;
};
interface TimelineStore {
- // Private track storage
- _tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
+ // Private track storage
+ _tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
- // Always returns properly ordered tracks with main track ensured
- tracks: TimelineTrack[];
+ // Always returns properly ordered tracks with main track ensured
+ tracks: TimelineTrack[];
- // Manual method if you need to force recomputation
- getSortedTracks: () => TimelineTrack[];
+ // Manual method if you need to force recomputation
+ getSortedTracks: () => TimelineTrack[];
- // Snapping settings
- snappingEnabled: boolean;
+ // Snapping settings
+ snappingEnabled: boolean;
- // Snapping actions
- toggleSnapping: () => void;
+ // Snapping actions
+ toggleSnapping: () => void;
- // Ripple editing mode
- rippleEditingEnabled: boolean;
- toggleRippleEditing: () => void;
+ // Ripple editing mode
+ rippleEditingEnabled: boolean;
+ toggleRippleEditing: () => void;
- // Multi-selection
- selectedElements: { trackId: string; elementId: string }[];
- selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
- deselectElement: (trackId: string, elementId: string) => void;
- clearSelectedElements: () => void;
- setSelectedElements: (
- elements: { trackId: string; elementId: string }[],
- ) => void;
+ // Multi-selection
+ selectedElements: { trackId: string; elementId: string }[];
+ selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
+ deselectElement: (trackId: string, elementId: string) => void;
+ clearSelectedElements: () => void;
+ setSelectedElements: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
- // Drag state
- dragState: {
- isDragging: boolean;
- elementId: string | null;
- trackId: string | null;
- startMouseX: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- };
- setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
- startDrag: (
- elementId: string,
- trackId: string,
- startMouseX: number,
- startElementTime: number,
- clickOffsetTime: number,
- ) => void;
- updateDragTime: (currentTime: number) => void;
- endDrag: () => void;
+ // Drag state
+ dragState: {
+ isDragging: boolean;
+ elementId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ };
+ setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
+ startDrag: (
+ elementId: string,
+ trackId: string,
+ startMouseX: number,
+ startElementTime: number,
+ clickOffsetTime: number
+ ) => void;
+ updateDragTime: (currentTime: number) => void;
+ endDrag: () => void;
- // Actions
- addTrack: (type: TrackType) => string;
- insertTrackAt: (type: TrackType, index: number) => string;
- removeTrack: (trackId: string) => void;
- removeTrackWithRipple: (trackId: string) => void;
- addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
- removeElementFromTrack: (trackId: string, elementId: string) => void;
- moveElementToTrack: (
- fromTrackId: string,
- toTrackId: string,
- elementId: string,
- ) => void;
- updateElementTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number,
- ) => void;
- updateElementDuration: (
- trackId: string,
- elementId: string,
- duration: number,
- ) => void;
- updateElementStartTime: (
- trackId: string,
- elementId: string,
- startTime: number,
- ) => void;
- toggleTrackMute: (trackId: string) => void;
+ // Actions
+ addTrack: (type: TrackType) => string;
+ insertTrackAt: (type: TrackType, index: number) => string;
+ removeTrack: (trackId: string) => void;
+ removeTrackWithRipple: (trackId: string) => void;
+ addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
+ removeElementFromTrack: (trackId: string, elementId: string) => void;
+ moveElementToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ elementId: string
+ ) => void;
+ updateElementTrim: (
+ trackId: string,
+ elementId: string,
+ trimStart: number,
+ trimEnd: number,
+ pushHistory?: boolean
+ ) => void;
+ updateElementDuration: (
+ trackId: string,
+ elementId: string,
+ duration: number,
+ pushHistory?: boolean
+ ) => void;
+ updateElementStartTime: (
+ trackId: string,
+ elementId: string,
+ startTime: number,
+ pushHistory?: boolean
+ ) => void;
+ toggleTrackMute: (trackId: string) => void;
- // Split operations for elements
- splitElement: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => string | null;
- splitAndKeepLeft: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- splitAndKeepRight: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- separateAudio: (trackId: string, elementId: string) => string | null;
+ // Split operations for elements
+ splitElement: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => string | null;
+ splitAndKeepLeft: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => void;
+ splitAndKeepRight: (
+ trackId: string,
+ 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<boolean>;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File
+ ) => Promise<boolean>;
- // Ripple editing functions
- updateElementStartTimeWithRipple: (
- trackId: string,
- elementId: string,
- newStartTime: number,
- ) => void;
- removeElementFromTrackWithRipple: (
- trackId: string,
- elementId: string,
- ) => void;
+ // Ripple editing functions
+ updateElementStartTimeWithRipple: (
+ trackId: string,
+ elementId: string,
+ newStartTime: number
+ ) => void;
+ removeElementFromTrackWithRipple: (
+ trackId: string,
+ elementId: string
+ ) => void;
- // Computed values
- getTotalDuration: () => number;
- getProjectThumbnail: (projectId: string) => Promise<string | null>;
+ // Computed values
+ getTotalDuration: () => number;
+ getProjectThumbnail: (projectId: string) => Promise<string | null>;
- // History actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
+ // History actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
- // Persistence actions
- loadProjectTimeline: (projectId: string) => Promise<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
- clearTimeline: () => void;
- updateTextElement: (
- trackId: string,
- elementId: string,
- updates: Partial<
- Pick<
- TextElement,
- | "content"
- | "fontSize"
- | "fontFamily"
- | "color"
- | "backgroundColor"
- | "textAlign"
- | "fontWeight"
- | "fontStyle"
- | "textDecoration"
- | "x"
- | "y"
- | "rotation"
- | "opacity"
- >
- >,
- ) => void;
- checkElementOverlap: (
- trackId: string,
- startTime: number,
- duration: number,
- excludeElementId?: string,
- ) => boolean;
- findOrCreateTrack: (trackType: TrackType) => string;
- addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
- addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
- addMediaToNewTrack: (item: MediaItem) => boolean;
- addTextToNewTrack: (item: TextElement | DragData) => boolean;
+ // Persistence actions
+ loadProjectTimeline: (projectId: string) => Promise<void>;
+ saveProjectTimeline: (projectId: string) => Promise<void>;
+ clearTimeline: () => void;
+ updateTextElement: (
+ trackId: string,
+ elementId: string,
+ updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ | "x"
+ | "y"
+ | "rotation"
+ | "opacity"
+ >
+ >
+ ) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ excludeElementId?: string
+ ) => boolean;
+ findOrCreateTrack: (trackType: TrackType) => string;
+ addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
+ addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
+ addMediaToNewTrack: (item: MediaItem) => boolean;
+ addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
- // Helper to update tracks and maintain ordering
- const updateTracks = (newTracks: TimelineTrack[]) => {
- const tracksWithMain = ensureMainTrack(newTracks);
- const sortedTracks = sortTracksByOrder(tracksWithMain);
- set({
- _tracks: tracksWithMain,
- tracks: sortedTracks,
- });
- };
+ // Helper to update tracks and maintain ordering
+ const updateTracks = (newTracks: TimelineTrack[]) => {
+ const tracksWithMain = ensureMainTrack(newTracks);
+ const sortedTracks = sortTracksByOrder(tracksWithMain);
+ set({
+ _tracks: tracksWithMain,
+ tracks: sortedTracks,
+ });
+ };
+
+ // Helper to auto-save timeline changes
+ const autoSaveTimeline = async () => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (activeProject) {
+ try {
+ await storageService.saveTimeline(activeProject.id, get()._tracks);
+ } catch (error) {
+ console.error("Failed to auto-save timeline:", error);
+ }
+ }
+ };
- // Helper to auto-save timeline changes
- const autoSaveTimeline = async () => {
- const activeProject = useProjectStore.getState().activeProject;
- if (activeProject) {
- try {
- await storageService.saveTimeline(activeProject.id, get()._tracks);
- } catch (error) {
- console.error("Failed to auto-save timeline:", error);
- }
- }
- };
+ // Helper to update tracks and auto-save
+ const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
+ updateTracks(newTracks);
+ // Auto-save in background
+ setTimeout(autoSaveTimeline, 100);
+ };
- // Helper to update tracks and auto-save
- const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
- updateTracks(newTracks);
- // Auto-save in background
- setTimeout(autoSaveTimeline, 100);
- };
+ // Initialize with proper track ordering
+ const initialTracks = ensureMainTrack([]);
+ const sortedInitialTracks = sortTracksByOrder(initialTracks);
- // Initialize with proper track ordering
- const initialTracks = ensureMainTrack([]);
- const sortedInitialTracks = sortTracksByOrder(initialTracks);
+ return {
+ _tracks: initialTracks,
+ tracks: sortedInitialTracks,
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+ rippleEditingEnabled: false,
- return {
- _tracks: initialTracks,
- tracks: sortedInitialTracks,
- history: [],
- redoStack: [],
- selectedElements: [],
- rippleEditingEnabled: false,
+ // Snapping settings defaults
+ snappingEnabled: true,
- // Snapping settings defaults
- snappingEnabled: true,
+ getSortedTracks: () => {
+ const { _tracks } = get();
+ const tracksWithMain = ensureMainTrack(_tracks);
+ return sortTracksByOrder(tracksWithMain);
+ },
- getSortedTracks: () => {
- const { _tracks } = get();
- const tracksWithMain = ensureMainTrack(_tracks);
- return sortTracksByOrder(tracksWithMain);
- },
+ pushHistory: () => {
+ const { _tracks, history } = get();
+ set({
+ history: [...history, JSON.parse(JSON.stringify(_tracks))],
+ redoStack: [],
+ });
+ },
- pushHistory: () => {
- const { _tracks, history } = get();
- set({
- history: [...history, JSON.parse(JSON.stringify(_tracks))],
- redoStack: [],
- });
- },
+ undo: () => {
+ const { history, redoStack, _tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 1];
+ updateTracksAndSave(prev);
+ set({
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
+ });
+ },
- undo: () => {
- const { history, redoStack, _tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- updateTracksAndSave(prev);
- set({
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
- });
- },
+ selectElement: (trackId, elementId, multi = false) => {
+ set((state) => {
+ const exists = state.selectedElements.some(
+ (c) => c.trackId === trackId && c.elementId === elementId
+ );
+ if (multi) {
+ return exists
+ ? {
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }
+ : {
+ selectedElements: [
+ ...state.selectedElements,
+ { trackId, elementId },
+ ],
+ };
+ } else {
+ return { selectedElements: [{ trackId, elementId }] };
+ }
+ });
+ },
- selectElement: (trackId, elementId, multi = false) => {
- set((state) => {
- const exists = state.selectedElements.some(
- (c) => c.trackId === trackId && c.elementId === elementId,
- );
- if (multi) {
- return exists
- ? {
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }
- : {
- selectedElements: [
- ...state.selectedElements,
- { trackId, elementId },
- ],
- };
- } else {
- return { selectedElements: [{ trackId, elementId }] };
- }
- });
- },
+ deselectElement: (trackId, elementId) => {
+ set((state) => ({
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }));
+ },
- deselectElement: (trackId, elementId) => {
- set((state) => ({
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }));
- },
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
+ },
- clearSelectedElements: () => {
- set({ selectedElements: [] });
- },
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
- setSelectedElements: (elements) => set({ selectedElements: elements }),
+ addTrack: (type) => {
+ get().pushHistory();
- addTrack: (type) => {
- get().pushHistory();
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ updateTracksAndSave([...get()._tracks, newTrack]);
+ return newTrack.id;
+ },
- updateTracksAndSave([...get()._tracks, newTrack]);
- return newTrack.id;
- },
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
- insertTrackAt: (type, index) => {
- get().pushHistory();
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ const newTracks = [...get()._tracks];
+ newTracks.splice(index, 0, newTrack);
+ updateTracksAndSave(newTracks);
+ return newTrack.id;
+ },
- const newTracks = [...get()._tracks];
- newTracks.splice(index, 0, newTrack);
- updateTracksAndSave(newTracks);
- return newTrack.id;
- },
+ removeTrack: (trackId) => {
+ const { rippleEditingEnabled } = get();
- removeTrack: (trackId) => {
- const { rippleEditingEnabled } = get();
+ if (rippleEditingEnabled) {
+ get().removeTrackWithRipple(trackId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.filter((track) => track.id !== trackId)
+ );
+ }
+ },
- if (rippleEditingEnabled) {
- get().removeTrackWithRipple(trackId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.filter((track) => track.id !== trackId),
- );
- }
- },
+ removeTrackWithRipple: (trackId) => {
+ const { _tracks } = get();
+ const trackToRemove = _tracks.find((t) => t.id === trackId);
- removeTrackWithRipple: (trackId) => {
- const { _tracks } = get();
- const trackToRemove = _tracks.find((t) => t.id === trackId);
+ if (!trackToRemove) return;
- if (!trackToRemove) return;
+ get().pushHistory();
- get().pushHistory();
+ // If track has no elements, just remove it normally
+ if (trackToRemove.elements.length === 0) {
+ updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
+ return;
+ }
- // If track has no elements, just remove it normally
- if (trackToRemove.elements.length === 0) {
- updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
- return;
- }
+ // Find all the time ranges occupied by elements in the track being removed
+ const occupiedRanges = trackToRemove.elements.map((element) => ({
+ startTime: element.startTime,
+ endTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd),
+ }));
- // Find all the time ranges occupied by elements in the track being removed
- const occupiedRanges = trackToRemove.elements.map((element) => ({
- startTime: element.startTime,
- endTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd),
- }));
+ // Sort ranges by start time
+ occupiedRanges.sort((a, b) => a.startTime - b.startTime);
- // Sort ranges by start time
- occupiedRanges.sort((a, b) => a.startTime - b.startTime);
+ // Merge overlapping ranges to get consolidated gaps
+ const mergedRanges: Array<{
+ startTime: number;
+ endTime: number;
+ duration: number;
+ }> = [];
- // Merge overlapping ranges to get consolidated gaps
- const mergedRanges: Array<{
- startTime: number;
- endTime: number;
- duration: number;
- }> = [];
+ for (const range of occupiedRanges) {
+ if (mergedRanges.length === 0) {
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ } else {
+ const lastRange = mergedRanges[mergedRanges.length - 1];
+ if (range.startTime <= lastRange.endTime) {
+ // Overlapping or adjacent ranges, merge them
+ lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
+ lastRange.duration = lastRange.endTime - lastRange.startTime;
+ } else {
+ // Non-overlapping range, add as new
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ }
+ }
+ }
- for (const range of occupiedRanges) {
- if (mergedRanges.length === 0) {
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- } else {
- const lastRange = mergedRanges[mergedRanges.length - 1];
- if (range.startTime <= lastRange.endTime) {
- // Overlapping or adjacent ranges, merge them
- lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
- lastRange.duration = lastRange.endTime - lastRange.startTime;
- } else {
- // Non-overlapping range, add as new
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- }
- }
- }
+ // Remove the track and apply ripple effects to remaining tracks
+ const updatedTracks = _tracks
+ .filter((track) => track.id !== trackId)
+ .map((track) => {
+ const updatedElements = track.elements.map((element) => {
+ let newStartTime = element.startTime;
- // Remove the track and apply ripple effects to remaining tracks
- const updatedTracks = _tracks
- .filter((track) => track.id !== trackId)
- .map((track) => {
- const updatedElements = track.elements.map((element) => {
- let newStartTime = element.startTime;
+ // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
+ for (let i = mergedRanges.length - 1; i >= 0; i--) {
+ const gap = mergedRanges[i];
+ // If this element starts after the gap, shift it left by the gap duration
+ if (newStartTime >= gap.endTime) {
+ newStartTime -= gap.duration;
+ }
+ }
- // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
- for (let i = mergedRanges.length - 1; i >= 0; i--) {
- const gap = mergedRanges[i];
- // If this element starts after the gap, shift it left by the gap duration
- if (newStartTime >= gap.endTime) {
- newStartTime -= gap.duration;
- }
- }
+ return {
+ ...element,
+ startTime: Math.max(0, newStartTime),
+ };
+ });
- return {
- ...element,
- startTime: Math.max(0, newStartTime),
- };
- });
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...track, elements: resolvedElements };
+ }
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...track, elements: resolvedElements };
- }
+ return { ...track, elements: updatedElements };
+ });
- return { ...track, elements: updatedElements };
- });
+ updateTracksAndSave(updatedTracks);
+ },
- updateTracksAndSave(updatedTracks);
- },
+ addElementToTrack: (trackId, elementData) => {
+ get().pushHistory();
- addElementToTrack: (trackId, elementData) => {
- get().pushHistory();
+ // Validate element type matches track type
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
- // Validate element type matches track type
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) {
- console.error("Track not found:", trackId);
- return;
- }
+ // Use utility function for validation
+ const validation = validateElementTrackCompatibility(elementData, track);
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- // Use utility function for validation
- const validation = validateElementTrackCompatibility(elementData, track);
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
- // For media elements, validate mediaId exists
- if (elementData.type === "media" && !elementData.mediaId) {
- console.error("Media element must have mediaId");
- return;
- }
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
- // For text elements, validate required text properties
- if (elementData.type === "text" && !elementData.content) {
- console.error("Text element must have content");
- return;
- }
+ // Check if this is the first element being added to the timeline
+ const currentState = get();
+ const totalElementsInTimeline = currentState._tracks.reduce(
+ (total, track) => total + track.elements.length,
+ 0
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
- // Check if this is the first element being added to the timeline
- const currentState = get();
- const totalElementsInTimeline = currentState._tracks.reduce(
- (total, track) => total + track.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
+ const newElement: TimelineElement = {
+ ...elementData,
+ id: generateUUID(),
+ startTime: elementData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
+ } as TimelineElement; // Type assertion since we trust the caller passes valid data
- const newElement: TimelineElement = {
- ...elementData,
- id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- } as TimelineElement; // Type assertion since we trust the caller passes valid data
+ // If this is the first element and it's a media element, automatically set the project canvas size
+ // to match the media's aspect ratio and FPS (for videos)
+ if (isFirstElement && newElement.type === "media") {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === newElement.mediaId
+ );
- // If this is the first element and it's a media element, automatically set the project canvas size
- // to match the media's aspect ratio and FPS (for videos)
- if (isFirstElement && newElement.type === "media") {
- const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === newElement.mediaId,
- );
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem)
+ );
+ }
- if (
- mediaItem &&
- (mediaItem.type === "image" || mediaItem.type === "video")
- ) {
- const editorStore = useEditorStore.getState();
- editorStore.setCanvasSizeFromAspectRatio(
- getMediaAspectRatio(mediaItem),
- );
- }
+ // Set project FPS from the first video element
+ if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
+ const projectStore = useProjectStore.getState();
+ if (projectStore.activeProject) {
+ projectStore.updateProjectFps(mediaItem.fps);
+ }
+ }
+ }
- // Set project FPS from the first video element
- if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
- const projectStore = useProjectStore.getState();
- if (projectStore.activeProject) {
- projectStore.updateProjectFps(mediaItem.fps);
- }
- }
- }
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? { ...track, elements: [...track.elements, newElement] }
+ : track
+ )
+ );
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? { ...track, elements: [...track.elements, newElement] }
- : track,
- ),
- );
- },
+ get().selectElement(trackId, newElement.id);
+ },
- removeElementFromTrack: (trackId, elementId) => {
- const { rippleEditingEnabled } = get();
+ removeElementFromTrack: (trackId, elementId) => {
+ const { rippleEditingEnabled } = get();
- if (rippleEditingEnabled) {
- get().removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()
- ._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId,
- ),
- }
- : track,
- )
- .filter((track) => track.elements.length > 0),
- );
- }
- },
+ if (rippleEditingEnabled) {
+ get().removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ }
+ : track
+ )
+ .filter((track) => track.elements.length > 0)
+ );
+ }
+ },
- removeElementFromTrackWithRipple: (trackId, elementId) => {
- const { _tracks, rippleEditingEnabled } = get();
+ removeElementFromTrackWithRipple: (trackId, elementId) => {
+ const { _tracks, rippleEditingEnabled } = get();
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular removal
- get().removeElementFromTrack(trackId, elementId);
- return;
- }
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular removal
+ get().removeElementFromTrack(trackId, elementId);
+ return;
+ }
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element || !track) return;
+ if (!element || !track) return;
- get().pushHistory();
+ get().pushHistory();
- const elementStartTime = element.startTime;
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementEndTime = elementStartTime + elementDuration;
+ const elementStartTime = element.startTime;
+ const elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementEndTime = elementStartTime + elementDuration;
- // Remove the element and shift all elements that come after it
- const updatedTracks = _tracks
- .map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ // Remove the element and shift all elements that come after it
+ const updatedTracks = _tracks
+ .map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- const updatedElements = currentTrack.elements
- .filter((currentElement) => {
- // Remove the target element
- if (
- currentElement.id === elementId &&
- currentTrack.id === trackId
- ) {
- return false;
- }
- return true;
- })
- .map((currentElement) => {
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ const updatedElements = currentTrack.elements
+ .filter((currentElement) => {
+ // Remove the target element
+ if (
+ currentElement.id === elementId &&
+ currentTrack.id === trackId
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map((currentElement) => {
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- // Shift elements that start after the removed element
- if (currentElement.startTime >= elementEndTime) {
- return {
- ...currentElement,
- startTime: Math.max(
- 0,
- currentElement.startTime - elementDuration,
- ),
- };
- }
- return currentElement;
- });
+ // Shift elements that start after the removed element
+ if (currentElement.startTime >= elementEndTime) {
+ return {
+ ...currentElement,
+ startTime: Math.max(
+ 0,
+ currentElement.startTime - elementDuration
+ ),
+ };
+ }
+ return currentElement;
+ });
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- return { ...currentTrack, elements: updatedElements };
- })
- .filter((track) => track.elements.length > 0 || track.isMain);
+ return { ...currentTrack, elements: updatedElements };
+ })
+ .filter((track) => track.elements.length > 0 || track.isMain);
- updateTracksAndSave(updatedTracks);
- },
+ updateTracksAndSave(updatedTracks);
+ },
- moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
- get().pushHistory();
+ 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,
- );
+ 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;
+ if (!elementToMove || !toTrack) return;
- // Validate element type compatibility with target track
- const validation = validateElementTrackCompatibility(
- elementToMove,
- toTrack,
- );
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Validate element type compatibility with target track
+ const validation = validateElementTrackCompatibility(
+ elementToMove,
+ 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,
- ),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- elements: [...track.elements, elementToMove],
- };
- }
- return track;
- })
- .filter((track) => track.elements.length > 0);
+ const newTracks = get()
+ ._tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ return {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ };
+ } else if (track.id === toTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, elementToMove],
+ };
+ }
+ return track;
+ })
+ .filter((track) => track.elements.length > 0);
- updateTracksAndSave(newTracks);
- },
+ updateTracksAndSave(newTracks);
+ },
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, trimStart, trimEnd }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementTrim: (
+ trackId,
+ elementId,
+ trimStart,
+ trimEnd,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementDuration: (trackId, elementId, duration) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, duration } : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementDuration: (
+ trackId,
+ elementId,
+ duration,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, duration } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, startTime }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementStartTime: (
+ trackId,
+ elementId,
+ startTime,
+ pushHistory = true
+ ) => {
+ if (pushHistory) get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, startTime } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
- const { _tracks, rippleEditingEnabled } = get();
+ updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
+ const { _tracks, rippleEditingEnabled } = get();
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular update
- get().updateElementStartTime(trackId, elementId, newStartTime);
- return;
- }
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular update
+ get().updateElementStartTime(trackId, elementId, newStartTime);
+ return;
+ }
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element || !track) return;
+ if (!element || !track) return;
- get().pushHistory();
+ get().pushHistory();
- const oldStartTime = element.startTime;
- const oldEndTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- const newEndTime =
- newStartTime + (element.duration - element.trimStart - element.trimEnd);
- const timeDelta = newStartTime - oldStartTime;
+ const oldStartTime = element.startTime;
+ const oldEndTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ const newEndTime =
+ newStartTime + (element.duration - element.trimStart - element.trimEnd);
+ const timeDelta = newStartTime - oldStartTime;
- // Update tracks based on multi-track ripple setting
- const updatedTracks = _tracks.map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ // Update tracks based on multi-track ripple setting
+ const updatedTracks = _tracks.map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- const updatedElements = currentTrack.elements.map((currentElement) => {
- if (currentElement.id === elementId && currentTrack.id === trackId) {
- // Update the moved element
- return { ...currentElement, startTime: newStartTime };
- }
+ const updatedElements = currentTrack.elements.map((currentElement) => {
+ if (currentElement.id === elementId && currentTrack.id === trackId) {
+ // Update the moved element
+ return { ...currentElement, startTime: newStartTime };
+ }
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- // For ripple editing, we need to move elements that come after the moved element
- const currentElementStart = currentElement.startTime;
- const _currentElementEnd =
- currentElement.startTime +
- (currentElement.duration -
- currentElement.trimStart -
- currentElement.trimEnd);
+ // For ripple editing, we need to move elements that come after the moved element
+ const currentElementStart = currentElement.startTime;
+ const currentElementEnd =
+ currentElement.startTime +
+ (currentElement.duration -
+ currentElement.trimStart -
+ currentElement.trimEnd);
- // If moving element to the right (positive delta)
- if (timeDelta > 0) {
- // Move elements that start after the original position of the moved element
- if (currentElementStart >= oldEndTime) {
- return {
- ...currentElement,
- startTime: currentElementStart + timeDelta,
- };
- }
- }
- // If moving element to the left (negative delta)
- else if (timeDelta < 0) {
- // Move elements that start after the new position of the moved element
- if (
- currentElementStart >= newEndTime &&
- currentElementStart >= oldStartTime
- ) {
- return {
- ...currentElement,
- startTime: Math.max(0, currentElementStart + timeDelta),
- };
- }
- }
+ // If moving element to the right (positive delta)
+ if (timeDelta > 0) {
+ // Move elements that start after the original position of the moved element
+ if (currentElementStart >= oldEndTime) {
+ return {
+ ...currentElement,
+ startTime: currentElementStart + timeDelta,
+ };
+ }
+ }
+ // If moving element to the left (negative delta)
+ else if (timeDelta < 0) {
+ // Move elements that start after the new position of the moved element
+ if (
+ currentElementStart >= newEndTime &&
+ currentElementStart >= oldStartTime
+ ) {
+ return {
+ ...currentElement,
+ startTime: Math.max(0, currentElementStart + timeDelta),
+ };
+ }
+ }
- return currentElement;
- });
+ return currentElement;
+ });
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- return { ...currentTrack, elements: updatedElements };
- });
+ return { ...currentTrack, elements: updatedElements };
+ });
- updateTracksAndSave(updatedTracks);
- },
+ updateTracksAndSave(updatedTracks);
+ },
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track,
- ),
- );
- },
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track
+ )
+ );
+ },
- updateTextElement: (trackId, elementId, updates) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId && element.type === "text"
- ? { ...element, ...updates }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateTextElement: (trackId, elementId, updates) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId && element.type === "text"
+ ? { ...element, ...updates }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- splitElement: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ splitElement: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return null;
+ if (!element) return null;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- const secondElementId = generateUUID();
+ const secondElementId = generateUUID();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.flatMap((c) =>
- c.id === elementId
- ? [
- {
- ...c,
- trimEnd: c.trimEnd + secondDuration,
- name: getElementNameWithSuffix(c.name, "left"),
- },
- {
- ...c,
- id: secondElementId,
- startTime: splitTime,
- trimStart: c.trimStart + firstDuration,
- name: getElementNameWithSuffix(c.name, "right"),
- },
- ]
- : [c],
- ),
- }
- : track,
- ),
- );
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.flatMap((c) =>
+ c.id === elementId
+ ? [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getElementNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondElementId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getElementNameWithSuffix(c.name, "right"),
+ },
+ ]
+ : [c]
+ ),
+ }
+ : track
+ )
+ );
- return secondElementId;
- },
+ return secondElementId;
+ },
- // Split element and keep only the left portion
- splitAndKeepLeft: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the left portion
+ splitAndKeepLeft: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const durationToRemove =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const durationToRemove =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- trimEnd: c.trimEnd + durationToRemove,
- name: getElementNameWithSuffix(c.name, "left"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ trimEnd: c.trimEnd + durationToRemove,
+ name: getElementNameWithSuffix(c.name, "left"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // Split element and keep only the right portion
- splitAndKeepRight: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the right portion
+ splitAndKeepRight: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
+ const relativeTime = splitTime - element.startTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- startTime: splitTime,
- trimStart: c.trimStart + relativeTime,
- name: getElementNameWithSuffix(c.name, "right"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getElementNameWithSuffix(c.name, "right"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // 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);
+ // 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;
+ if (!element || track?.type !== "media") return null;
- get().pushHistory();
+ get().pushHistory();
- // Find existing audio track or prepare to create one
- const existingAudioTrack = _tracks.find((t) => t.type === "audio");
- const audioElementId = generateUUID();
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = _tracks.find((t) => t.type === "audio");
+ const audioElementId = generateUUID();
- if (existingAudioTrack) {
- // Add audio element to existing audio track
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === existingAudioTrack.id
- ? {
- ...track,
- elements: [
- ...track.elements,
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- }
- : track,
- ),
- );
- } else {
- // Create new audio track with the audio element in a single atomic update
- const newAudioTrack: TimelineTrack = {
- id: generateUUID(),
- name: "Audio Track",
- type: "audio",
- elements: [
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- muted: false,
- };
+ if (existingAudioTrack) {
+ // Add audio element to existing audio track
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ elements: [
+ ...track.elements,
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ }
+ : track
+ )
+ );
+ } else {
+ // Create new audio track with the audio element in a single atomic update
+ 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]);
- }
+ updateTracksAndSave([...get()._tracks, newAudioTrack]);
+ }
- return audioElementId;
- },
+ return audioElementId;
+ },
- // Replace media for an element
- replaceElementMedia: async (trackId, elementId, newFile) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Replace media for an element
+ replaceElementMedia: async (trackId, elementId, newFile) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element || element.type !== "media") return false;
+ if (!element || element.type !== "media") return false;
- try {
- const mediaStore = useMediaStore.getState();
- const projectStore = useProjectStore.getState();
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
- if (!projectStore.activeProject) return false;
+ if (!projectStore.activeProject) return false;
- // Import required media processing functions
- const {
- getFileType,
- getImageDimensions,
- generateVideoThumbnail,
- getMediaDuration,
- } = await import("./media-store");
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
- const fileType = getFileType(newFile);
- if (!fileType) return false;
+ const fileType = getFileType(newFile);
+ if (!fileType) return false;
- // Process the new media file
- const mediaData: {
- name: string;
- type: MediaType;
- file: File;
- url: string;
- duration?: number;
- thumbnailUrl?: string;
- width?: number;
- height?: number;
- } = {
- name: newFile.name,
- type: fileType,
- file: newFile,
- url: URL.createObjectURL(newFile),
- };
+ // Process the new media file
+ let mediaData: any = {
+ name: newFile.name,
+ type: fileType,
+ file: newFile,
+ url: URL.createObjectURL(newFile),
+ };
- // Get media-specific metadata
- 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);
- }
+ // Get media-specific metadata
+ 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);
+ }
- // Add new media item to store
- await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
+ // Add new media item to store
+ await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
- // Find the newly created media item
- const newMediaItem = mediaStore.mediaItems.find(
- (item) => item.file === newFile,
- );
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile
+ );
- if (!newMediaItem) return false;
+ if (!newMediaItem) return false;
- get().pushHistory();
+ get().pushHistory();
- // Update the timeline element to reference the new media
- updateTracksAndSave(
- _tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- mediaId: newMediaItem.id,
- name: newMediaItem.name,
- // Update duration if the new media has a different duration
- duration: newMediaItem.duration || c.duration,
- }
- : c,
- ),
- }
- : track,
- ),
- );
+ // Update the timeline element to reference the new media
+ updateTracksAndSave(
+ _tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ mediaId: newMediaItem.id,
+ name: newMediaItem.name,
+ // Update duration if the new media has a different duration
+ duration: newMediaItem.duration || c.duration,
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
- return true;
- } catch (error) {
- console.log(
- JSON.stringify({
- error: "Failed to replace element media",
- details: error,
- }),
- );
- return false;
- }
- },
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ })
+ );
+ return false;
+ }
+ },
- getTotalDuration: () => {
- const { _tracks } = get();
- if (_tracks.length === 0) return 0;
+ getTotalDuration: () => {
+ const { _tracks } = get();
+ if (_tracks.length === 0) return 0;
- const trackEndTimes = _tracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
+ const trackEndTimes = _tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+ return Math.max(maxEnd, elementEnd);
+ }, 0)
+ );
- return Math.max(...trackEndTimes, 0);
- },
+ return Math.max(...trackEndTimes, 0);
+ },
- getProjectThumbnail: async (projectId) => {
- try {
- const tracks = await storageService.loadTimeline(projectId);
- const mediaItems = await storageService.loadAllMediaItems(projectId);
+ getProjectThumbnail: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ const mediaItems = await storageService.loadAllMediaItems(projectId);
- if (!tracks || !mediaItems.length) return null;
+ if (!tracks || !mediaItems.length) return null;
- const firstMediaElement = tracks
- .flatMap((track) => track.elements)
- .filter((element) => element.type === "media")
- .sort((a, b) => a.startTime - b.startTime)[0];
+ const firstMediaElement = tracks
+ .flatMap((track) => track.elements)
+ .filter((element) => element.type === "media")
+ .sort((a, b) => a.startTime - b.startTime)[0];
- if (!firstMediaElement) return null;
+ if (!firstMediaElement) return null;
- const mediaItem = mediaItems.find(
- (item) => item.id === firstMediaElement.mediaId,
- );
- if (!mediaItem) return null;
+ const mediaItem = mediaItems.find(
+ (item) => item.id === firstMediaElement.mediaId
+ );
+ if (!mediaItem) return null;
- if (mediaItem.type === "video" && mediaItem.file) {
- const { generateVideoThumbnail } = await import(
- "@/stores/media-store"
- );
- const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
- return thumbnailUrl;
- } else if (mediaItem.type === "image" && mediaItem.url) {
- return mediaItem.url;
- }
+ if (mediaItem.type === "video" && mediaItem.file) {
+ const { generateVideoThumbnail } = await import(
+ "@/stores/media-store"
+ );
+ const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
+ return thumbnailUrl;
+ } else if (mediaItem.type === "image" && mediaItem.url) {
+ return mediaItem.url;
+ }
- return null;
- } catch (error) {
- console.error("Failed to get project thumbnail:", error);
- return null;
- }
- },
+ return null;
+ } catch (error) {
+ console.error("Failed to get project thumbnail:", error);
+ return null;
+ }
+ },
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- updateTracksAndSave(next);
- set({ redoStack: redoStack.slice(0, -1) });
- },
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ updateTracksAndSave(next);
+ set({ redoStack: redoStack.slice(0, -1) });
+ },
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
- setDragState: (dragState) =>
- set((state) => ({
- dragState: { ...state.dragState, ...dragState },
- })),
+ setDragState: (dragState) =>
+ set((state) => ({
+ dragState: { ...state.dragState, ...dragState },
+ })),
- startDrag: (
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- ) => {
- set({
- dragState: {
- isDragging: true,
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- currentTime: startElementTime,
- },
- });
- },
+ startDrag: (
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime
+ ) => {
+ set({
+ dragState: {
+ isDragging: true,
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: startElementTime,
+ },
+ });
+ },
- updateDragTime: (currentTime) => {
- set((state) => ({
- dragState: {
- ...state.dragState,
- currentTime,
- },
- }));
- },
+ updateDragTime: (currentTime) => {
+ set((state) => ({
+ dragState: {
+ ...state.dragState,
+ currentTime,
+ },
+ }));
+ },
- endDrag: () => {
- set({
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
- });
- },
+ endDrag: () => {
+ set({
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
+ });
+ },
- // Persistence methods
- loadProjectTimeline: async (projectId) => {
- try {
- const tracks = await storageService.loadTimeline(projectId);
- if (tracks) {
- updateTracks(tracks);
- } else {
- // No timeline saved yet, initialize with default
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- }
- // Clear history when loading a project
- set({ history: [], redoStack: [] });
- } catch (error) {
- console.error("Failed to load timeline:", error);
- // Initialize with default on error
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [] });
- }
- },
+ // Persistence methods
+ loadProjectTimeline: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ if (tracks) {
+ updateTracks(tracks);
+ } else {
+ // No timeline saved yet, initialize with default
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ }
+ // Clear history when loading a project
+ set({ history: [], redoStack: [] });
+ } catch (error) {
+ console.error("Failed to load timeline:", error);
+ // Initialize with default on error
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [] });
+ }
+ },
- saveProjectTimeline: async (projectId) => {
- try {
- await storageService.saveTimeline(projectId, get()._tracks);
- } catch (error) {
- console.error("Failed to save timeline:", error);
- }
- },
+ saveProjectTimeline: async (projectId) => {
+ try {
+ await storageService.saveTimeline(projectId, get()._tracks);
+ } catch (error) {
+ console.error("Failed to save timeline:", error);
+ }
+ },
- clearTimeline: () => {
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
- },
+ clearTimeline: () => {
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [], selectedElements: [] });
+ },
- // Snapping actions
- toggleSnapping: () => {
- set((state) => ({ snappingEnabled: !state.snappingEnabled }));
- },
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
- // Ripple editing functions
- toggleRippleEditing: () => {
- set((state) => ({
- rippleEditingEnabled: !state.rippleEditingEnabled,
- }));
- },
+ // Ripple editing functions
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
- checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) return false;
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
- const overlap = track.elements.some((element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
- if (element.id === excludeElementId) {
- return false;
- }
+ if (element.id === excludeElementId) {
+ return false;
+ }
- return (
- (startTime >= element.startTime && startTime < elementEnd) ||
- (startTime + duration > element.startTime &&
- startTime + duration <= elementEnd) ||
- (startTime < element.startTime && startTime + duration > elementEnd)
- );
- });
- return overlap;
- },
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
- findOrCreateTrack: (trackType) => {
- // Always create new text track to allow multiple text elements
- if (trackType === "text") {
- return get().addTrack(trackType);
- }
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ // Insert text tracks at the top
+ if (trackType === "text") {
+ return get().insertTrackAt(trackType, 0);
+ }
- const existingTrack = get()._tracks.find((t) => t.type === trackType);
- if (existingTrack) {
- return existingTrack.id;
- }
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
- return get().addTrack(trackType);
- },
+ return get().addTrack(trackType);
+ },
- addMediaAtTime: (item, currentTime = 0) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- const duration =
- item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
- if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements",
- );
- return false;
- }
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return false;
+ }
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextAtTime: (item, currentTime = 0) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content: item.content || "Default Text",
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: item.fontSize || 48,
- fontFamily: item.fontFamily || "Arial",
- color: item.color || "#ffffff",
- backgroundColor: item.backgroundColor || "transparent",
- textAlign: item.textAlign || "center",
- fontWeight: item.fontWeight || "normal",
- fontStyle: item.fontStyle || "normal",
- textDecoration: item.textDecoration || "none",
- x: item.x || 0,
- y: item.y || 0,
- rotation: item.rotation || 0,
- opacity: item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content: item.content || "Default Text",
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: item.fontSize || 48,
+ fontFamily: item.fontFamily || "Arial",
+ color: item.color || "#ffffff",
+ backgroundColor: item.backgroundColor || "transparent",
+ textAlign: item.textAlign || "center",
+ fontWeight: item.fontWeight || "normal",
+ fontStyle: item.fontStyle || "normal",
+ textDecoration: item.textDecoration || "none",
+ x: item.x || 0,
+ y: item.y || 0,
+ rotation: item.rotation || 0,
+ opacity: item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
- addMediaToNewTrack: (item) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextToNewTrack: (item) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content:
- ("content" in item ? item.content : "Default Text") || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
- fontFamily:
- ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
- color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
- backgroundColor:
- ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
- "transparent",
- textAlign:
- ("textAlign" in item ? item.textAlign : "center") || "center",
- fontWeight:
- ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
- fontStyle:
- ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
- textDecoration:
- ("textDecoration" in item ? item.textDecoration : "none") || "none",
- x: ("x" in item ? item.x : 0) || 0,
- y: ("y" in item ? item.y : 0) || 0,
- rotation: ("rotation" in item ? item.rotation : 0) || 0,
- opacity:
- "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
- };
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content:
+ ("content" in item ? item.content : "Default Text") || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
+ fontFamily:
+ ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
+ color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
+ backgroundColor:
+ ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
+ "transparent",
+ textAlign:
+ ("textAlign" in item ? item.textAlign : "center") || "center",
+ fontWeight:
+ ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
+ fontStyle:
+ ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
+ textDecoration:
+ ("textDecoration" in item ? item.textDecoration : "none") || "none",
+ x: ("x" in item ? item.x : 0) || 0,
+ y: ("y" in item ? item.y : 0) || 0,
+ rotation: ("rotation" in item ? item.rotation : 0) || 0,
+ opacity:
+ "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
+ };
});
diff --git a/apps/web/src/types/post.ts b/apps/web/src/types/post.ts
new file mode 100644
index 00000000..c3436847
--- /dev/null
+++ b/apps/web/src/types/post.ts
@@ -0,0 +1,93 @@
+export type Post = {
+ id: string;
+ slug: string;
+ title: string;
+ content: string;
+ description: string;
+ coverImage: string;
+ publishedAt: Date;
+ updatedAt: Date;
+ authors: {
+ id: string;
+ name: string;
+ image: string;
+ }[];
+ category: {
+ id: string;
+ slug: string;
+ name: string;
+ };
+ tags: {
+ id: string;
+ slug: string;
+ name: string;
+ }[];
+ attribution: {
+ author: string;
+ url: string;
+ } | null;
+ };
+
+ export type Pagination = {
+ limit: number;
+ currpage: number;
+ nextPage: number | null;
+ prevPage: number | null;
+ totalItems: number;
+ totalPages: number;
+ };
+
+ export type MarblePostList = {
+ posts: Post[];
+ pagination: Pagination;
+ };
+
+ export type MarblePost = {
+ post: Post;
+ };
+
+ export type Tag = {
+ id: string;
+ name: string;
+ slug: string;
+ };
+
+ export type MarbleTag = {
+ tag: Tag;
+ };
+
+ export type MarbleTagList = {
+ tags: Tag[];
+ pagination: Pagination;
+ };
+
+ export type Category = {
+ id: string;
+ name: string;
+ slug: string;
+ };
+
+ export type MarbleCategory = {
+ category: Category;
+ };
+
+ export type MarbleCategoryList = {
+ categories: Category[];
+ pagination: Pagination;
+ };
+
+ export type Author = {
+ id: string;
+ name: string;
+ image: string;
+ };
+
+ export type MarbleAuthor = {
+ author: Author;
+ };
+
+ export type MarbleAuthorList = {
+ authors: Author[];
+ pagination: Pagination;
+ };
+ \ No newline at end of file
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 17dbb5d1..f8a79d03 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -89,13 +89,19 @@ export interface TimelineTrack {
export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
return [...tracks].sort((a, b) => {
+ // Text tracks always go to the top
+ if (a.type === "text" && b.type !== "text") return -1;
+ if (b.type === "text" && a.type !== "text") return 1;
+
// Audio tracks always go to bottom
if (a.type === "audio" && b.type !== "audio") return 1;
if (b.type === "audio" && a.type !== "audio") return -1;
- // Main track goes above audio but below other tracks
- if (a.isMain && !b.isMain && b.type !== "audio") return 1;
- if (b.isMain && !a.isMain && a.type !== "audio") return -1;
+ // Main track goes above audio but below text tracks
+ if (a.isMain && !b.isMain && b.type !== "audio" && b.type !== "text")
+ return 1;
+ if (b.isMain && !a.isMain && a.type !== "audio" && a.type !== "text")
+ return -1;
// Within same category, maintain creation order
return 0;