diff options
Diffstat (limited to 'apps/web/src')
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; |
