summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx4
-rw-r--r--apps/web/src/app/layout.tsx9
-rw-r--r--apps/web/src/app/metadata.ts138
-rw-r--r--apps/web/src/app/privacy/page.tsx253
-rw-r--r--apps/web/src/app/roadmap/page.tsx247
-rw-r--r--apps/web/src/app/terms/page.tsx317
-rw-r--r--apps/web/src/components/development-debug.tsx108
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx52
-rw-r--r--apps/web/src/components/editor/snap-indicator.tsx54
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx14
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx187
-rw-r--r--apps/web/src/components/editor/timeline.tsx332
-rw-r--r--apps/web/src/components/footer.tsx264
-rw-r--r--apps/web/src/components/landing/hero.tsx18
-rw-r--r--apps/web/src/components/ui/resizable.tsx3
-rw-r--r--apps/web/src/components/ui/tooltip.tsx2
-rw-r--r--apps/web/src/env.ts27
-rw-r--r--apps/web/src/hooks/use-timeline-snapping.ts178
-rw-r--r--apps/web/src/lib/rate-limit.ts5
-rw-r--r--apps/web/src/middleware.ts3
-rw-r--r--apps/web/src/stores/timeline-store.ts14
21 files changed, 1669 insertions, 560 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index db17dd4a..315e0764 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -60,7 +60,7 @@ export default function Editor() {
<div className="flex-1 min-h-0 min-w-0">
<ResizablePanelGroup
direction="vertical"
- className="h-full w-full gap-1"
+ className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
@@ -72,7 +72,7 @@ export default function Editor() {
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
- className="h-full w-full gap-1 px-2"
+ className="h-full w-full gap-[0.19rem] px-2"
>
{/* Tools Panel */}
<ResizablePanel
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index cb026566..9ac54262 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,7 +4,6 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
-import { DevelopmentDebug } from "../components/development-debug";
import { StorageProvider } from "../components/storage-provider";
import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
@@ -24,16 +23,16 @@ export default function RootLayout({
<StorageProvider>{children}</StorageProvider>
<Analytics />
<Toaster />
- <DevelopmentDebug />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
- data-track-attributes={true}
+ data-track-attributes={false}
data-track-errors={true}
- data-track-outgoing-links={true}
- data-track-web-vitals={true}
+ data-track-outgoing-links={false}
+ data-track-web-vitals={false}
+ data-track-sessions={false}
/>
</TooltipProvider>
</ThemeProvider>
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 4e387014..47f0020b 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -1,66 +1,88 @@
import { Metadata } from "next";
const title = "OpenCut";
-const description = "A simple but powerful video editor that gets the job done. In your browser.";
-const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
-const twitterImageUrl = "/opengraph-image.jpg";
+const description =
+ "A simple but powerful video editor that gets the job done. In your browser.";
+const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg";
+const twitterImageUrl = "/open-graph/default.jpg";
export const baseMetaData: Metadata = {
+ metadataBase: new URL("https://opencut.app"),
+ title: title,
+ description: description,
+ openGraph: {
title: title,
description: description,
- openGraph: {
- title: title,
- description: description,
- url: "https://opencut.app",
- siteName: "OpenCut",
- locale: "en_US",
- type: "website",
- images: [
- {
- url: openGraphImageUrl,
- width: 1200,
- height: 630,
- alt: "OpenCut",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: title,
- description: description,
- creator: "@opencutapp",
- images: [twitterImageUrl],
- },
- robots: {
- index: true,
- follow: true,
- },
- icons: {
- icon: [
- { url: "/favicon.ico" },
- { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
- { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
- { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
- ],
- apple: [
- { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
- { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
- { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
- { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
- { url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
- { url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
- { url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
- { url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
- { url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
- ],
- shortcut: ["/favicon.ico"]
- },
- appleWebApp: {
- capable: true,
- title: title,
- },
- manifest: "/manifest.json",
- other: {
- "msapplication-config": "/browserconfig.xml"
- }
+ url: "https://opencut.app",
+ siteName: "OpenCut",
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: openGraphImageUrl,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: title,
+ description: description,
+ creator: "@opencutapp",
+ images: [twitterImageUrl],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+ icons: {
+ icon: [
+ { url: "/favicon.ico" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ ],
+ apple: [
+ { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
+ { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
+ { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
+ { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
+ {
+ url: "/icons/apple-icon-114x114.png",
+ sizes: "114x114",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-120x120.png",
+ sizes: "120x120",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-144x144.png",
+ sizes: "144x144",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-152x152.png",
+ sizes: "152x152",
+ type: "image/png",
+ },
+ {
+ url: "/icons/apple-icon-180x180.png",
+ sizes: "180x180",
+ type: "image/png",
+ },
+ ],
+ shortcut: ["/favicon.ico"],
+ },
+ appleWebApp: {
+ capable: true,
+ title: title,
+ },
+ manifest: "/manifest.json",
+ other: {
+ "msapplication-config": "/browserconfig.xml",
+ },
};
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
new file mode 100644
index 00000000..42dd0f9a
--- /dev/null
+++ b/apps/web/src/app/privacy/page.tsx
@@ -0,0 +1,253 @@
+import { Metadata } from "next";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+
+export const metadata: Metadata = {
+ title: "Privacy Policy - OpenCut",
+ description:
+ "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
+ openGraph: {
+ title: "Privacy Policy - OpenCut",
+ description:
+ "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
+ type: "website",
+ },
+};
+
+export default function PrivacyPage() {
+ 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 mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Privacy Policy
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Learn how we handle your data and privacy. Contact us if you
+ have any questions.
+ </p>
+ </div>
+ <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
+ <CardContent className="p-8 text-base leading-relaxed space-y-8">
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Your Videos Stay Private
+ </h2>
+ <p className="mb-4">
+ <strong>
+ OpenCut processes all videos locally on your device.
+ </strong>{" "}
+ We never upload, store, or have access to your video files.
+ Your content remains completely private and under your
+ control at all times.
+ </p>
+ <p>
+ All video editing, rendering, and processing happens in your
+ browser using WebAssembly and local storage. No video data
+ is transmitted to our servers.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Account Information
+ </h2>
+ <p className="mb-4">
+ When you create an account, we only collect:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Email address (for account access)</li>
+ <li>
+ Profile information from Google OAuth (if you choose to
+ sign in with Google)
+ </li>
+ </ul>
+ <p className="mb-4">
+ <strong>
+ We do NOT store your projects on our servers.
+ </strong>{" "}
+ All project data, including names, thumbnails, and creation
+ dates, is stored locally in your browser using IndexedDB.
+ </p>
+ <p>
+ We use{" "}
+ <a
+ href="https://www.better-auth.com"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ Better Auth
+ </a>{" "}
+ for secure authentication and follow industry-standard
+ security practices.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Analytics</h2>
+ <p className="mb-4">
+ We use{" "}
+ <a
+ href="https://www.databuddy.cc"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ Databuddy
+ </a>{" "}
+ for completely anonymized and non-invasive analytics to
+ understand how people use OpenCut.
+ </p>
+ <p>
+ This helps us improve the editor, but we never collect
+ personal information, track individual users, or store any
+ data that could identify you.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Local Storage & Cookies
+ </h2>
+ <p className="mb-4">
+ We use browser local storage and IndexedDB to:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Save your projects locally on your device</li>
+ <li>Remember your editor preferences and settings</li>
+ <li>Keep you logged in across browser sessions</li>
+ </ul>
+ <p>
+ All data stays on your device and can be cleared at any time
+ through your browser settings.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Third-Party Services
+ </h2>
+ <p className="mb-4">
+ OpenCut integrates with these services:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ <strong>Google OAuth:</strong> For optional Google sign-in
+ (governed by Google's privacy policy)
+ </li>
+ <li>
+ <strong>Vercel:</strong> For hosting and content delivery
+ </li>
+ <li>
+ <strong>Databuddy:</strong> For anonymized analytics
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
+ <p className="mb-4">
+ You have complete control over your data:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Delete your account and all associated data at any time
+ </li>
+ <li>Export your project data</li>
+ <li>Clear local storage to remove all saved projects</li>
+ <li>Contact us with any privacy concerns</li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Open Source Transparency
+ </h2>
+ <p className="mb-4">
+ OpenCut is completely open source. You can review our code,
+ see exactly how we handle data, and even self-host the
+ application if you prefer.
+ </p>
+ <p>
+ View our source code on{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Contact Us</h2>
+ <p className="mb-4">
+ Questions about this privacy policy or how we handle your
+ data?
+ </p>
+ <p>
+ Open an issue on our{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub repository
+ </a>
+ , email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ , or reach out on{" "}
+ <a
+ href="https://x.com/opencutapp"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ X (Twitter)
+ </a>
+ .
+ </p>
+ </section>
+
+ <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
+ Last updated: July 14, 2025
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
new file mode 100644
index 00000000..c62dce3a
--- /dev/null
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -0,0 +1,247 @@
+import { Metadata } from "next";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+import ReactMarkdown from "react-markdown";
+import { cn } from "@/lib/utils";
+
+const roadmapItems: {
+ title: string;
+ description: string;
+ status: {
+ text: string;
+ type: "complete" | "pending" | "default" | "info";
+ };
+}[] = [
+ {
+ title: "Start",
+ description:
+ "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Core UI",
+ description:
+ "Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Basic Functionality",
+ description:
+ "The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Export/Preview Logic",
+ description:
+ "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Text",
+ description:
+ "After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Effects",
+ description:
+ "Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Transitions",
+ description:
+ "Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Refine from Here",
+ description:
+ "Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
+ status: {
+ text: "Future",
+ type: "info",
+ },
+ },
+];
+
+export const metadata: Metadata = {
+ title: "Roadmap - OpenCut",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ openGraph: {
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ type: "website",
+ images: [
+ {
+ url: "/open-graph/roadmap.jpg",
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Roadmap",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ images: ["/open-graph/roadmap.jpg"],
+ },
+};
+
+export default function RoadmapPage() {
+ 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 mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Roadmap
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ What's coming next for OpenCut (last updated: July 14, 2025)
+ </p>
+ </div>
+ <div className="space-y-6">
+ {roadmapItems.map((item, index) => (
+ <div key={index} className="relative">
+ <div className="flex items-start gap-2">
+ <span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
+ {index + 1}.
+ </span>
+ <div className="flex-1 pt-[2px]">
+ <div className="flex items-center gap-2 mb-2">
+ <h3 className="font-medium text-lg">{item.title}</h3>
+ <Badge
+ className={cn("shadow-none", {
+ "!bg-green-500 text-white":
+ item.status.type === "complete",
+ "!bg-yellow-500 text-white":
+ item.status.type === "pending",
+ "!bg-blue-500 text-white":
+ item.status.type === "info",
+ "!bg-foreground/10 text-accent-foreground":
+ item.status.type === "default",
+ })}
+ >
+ {item.status.text}
+ </Badge>
+ </div>
+ <div className="text-foreground/70 leading-relaxed">
+ <ReactMarkdown
+ components={{
+ a: ({ className, children, ...props }) => (
+ <a
+ className={cn(
+ "text-primary hover:underline",
+ className
+ )}
+ target="_blank"
+ rel="noopener noreferrer"
+ {...props}
+ >
+ {children}
+ </a>
+ ),
+ strong: ({ children }) => (
+ <strong className="font-semibold text-foreground">
+ {children}
+ </strong>
+ ),
+ }}
+ >
+ {item.description}
+ </ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="mt-12 pt-8 border-t border-muted/20">
+ <div className="text-center space-y-4">
+ <h3 className="text-xl font-semibold">Want to Help?</h3>
+ <p className="text-muted-foreground max-w-2xl mx-auto">
+ OpenCut is open source and built by the community. Every
+ contribution, no matter how small, helps us build the best
+ free video editor possible.
+ </p>
+ <div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ <GithubIcon className="h-4 w-4 mr-2" />
+ Start Contributing
+ </Badge>
+ </Link>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ Report Issues
+ </Badge>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
new file mode 100644
index 00000000..c56e8fc1
--- /dev/null
+++ b/apps/web/src/app/terms/page.tsx
@@ -0,0 +1,317 @@
+import { Metadata } from "next";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+
+export const metadata: Metadata = {
+ title: "Terms of Service - OpenCut",
+ description:
+ "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
+ openGraph: {
+ title: "Terms of Service - OpenCut",
+ description:
+ "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
+ type: "website",
+ },
+};
+
+export default function TermsPage() {
+ 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 mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Terms of Service
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Fair and transparent terms for our free, open-source video
+ editor.
+ </p>
+ </div>
+ <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
+ <CardContent className="p-8 text-base leading-relaxed space-y-8">
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Welcome to OpenCut
+ </h2>
+ <p className="mb-4">
+ OpenCut is a free, open-source video editor that runs in
+ your browser. By using our service, you agree to these
+ terms. We've designed these terms to be fair and protect
+ both you and our project.
+ </p>
+ <p>
+ <strong>Key principle:</strong> Your content stays on your
+ device. We never claim ownership of your videos or projects.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Your Content, Your Rights
+ </h2>
+ <p className="mb-4">
+ <strong>You own everything you create.</strong> OpenCut
+ processes your videos locally on your device, so we never
+ have access to your content. We make no claims to ownership,
+ licensing, or rights over your videos, projects, or any
+ content you create using OpenCut.
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Your videos remain completely private and under your
+ control
+ </li>
+ <li>
+ You retain all intellectual property rights to your
+ content
+ </li>
+ <li>
+ You can export and use your content however you choose
+ </li>
+ <li>
+ No watermarks, no licensing restrictions from OpenCut
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ How You Can Use OpenCut
+ </h2>
+ <p className="mb-4">
+ OpenCut is free for personal and commercial use. You can:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Create videos for personal, educational, or commercial
+ purposes
+ </li>
+ <li>Use OpenCut for client work and paid projects</li>
+ <li>Share and distribute videos created with OpenCut</li>
+ <li>
+ Modify and distribute the OpenCut software (under MIT
+ license)
+ </li>
+ </ul>
+ <p>
+ <strong>What we ask:</strong> Don't use OpenCut for illegal
+ activities, harassment, or creating harmful content. Be
+ respectful of others and follow applicable laws.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Account and Service
+ </h2>
+ <p className="mb-4">
+ To use certain features, you may create an account:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Provide accurate information when signing up</li>
+ <li>
+ Keep your account secure and don't share credentials
+ </li>
+ <li>You're responsible for activity under your account</li>
+ <li>You can delete your account at any time</li>
+ </ul>
+ <p>
+ OpenCut is provided "as is" without warranties. While we
+ strive for reliability, we can't guarantee uninterrupted
+ service.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Open Source Benefits
+ </h2>
+ <p className="mb-4">
+ Because OpenCut is open source, you have additional rights:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Review our code to see exactly how we handle your data
+ </li>
+ <li>Self-host OpenCut on your own servers</li>
+ <li>Modify the software to suit your needs</li>
+ <li>Contribute improvements back to the community</li>
+ </ul>
+ <p>
+ View our source code and license on{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Third-Party Content
+ </h2>
+ <p className="mb-4">
+ When using OpenCut, make sure you have the right to use any
+ content you import:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Only upload content you own or have permission to use
+ </li>
+ <li>
+ Respect copyright, trademarks, and other intellectual
+ property
+ </li>
+ <li>
+ Don't use copyrighted music, images, or videos without
+ permission
+ </li>
+ <li>
+ You're responsible for any claims related to your content
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Limitations and Liability
+ </h2>
+ <p className="mb-4">
+ OpenCut is provided free of charge. To the extent permitted
+ by law:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>We're not liable for any loss of data or content</li>
+ <li>
+ Projects are stored in your browser and may be lost if you
+ clear browser data
+ </li>
+ <li>We're not responsible for how you use the service</li>
+ <li>
+ Our liability is limited to the maximum extent allowed by
+ law
+ </li>
+ </ul>
+ <p>
+ Since your content stays on your device, we have no way to
+ recover lost projects. Consider exporting important videos
+ when finished editing.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Service Changes
+ </h2>
+ <p className="mb-4">We may update OpenCut and these terms:</p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ We'll notify you of significant changes to these terms
+ </li>
+ <li>Continued use means you accept any updates</li>
+ <li>
+ You can always self-host an older version if you prefer
+ </li>
+ <li>
+ Major changes will be discussed with the community on
+ GitHub
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Termination</h2>
+ <p className="mb-4">
+ You can stop using OpenCut at any time:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Delete your account through your profile settings</li>
+ <li>Clear your browser data to remove local projects</li>
+ <li>
+ Your content remains yours even if you stop using OpenCut
+ </li>
+ <li>
+ We may suspend accounts for violations of these terms
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Contact and Disputes
+ </h2>
+ <p className="mb-4">
+ Questions about these terms or need to report an issue?
+ </p>
+ <p className="mb-4">
+ Contact us through our{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub repository
+ </a>
+ , email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ , or reach out on{" "}
+ <a
+ href="https://x.com/opencutapp"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ X (Twitter)
+ </a>
+ .
+ </p>
+ <p>
+ These terms are governed by applicable law in your
+ jurisdiction. We prefer to resolve disputes through friendly
+ discussion in our open-source community.
+ </p>
+ </section>
+
+ <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
+ Last updated: July 14, 2025
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx
deleted file mode 100644
index 3b891e38..00000000
--- a/apps/web/src/components/development-debug.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client";
-
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { TimelineTrack } from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { Button } from "@/components/ui/button";
-import { useState } from "react";
-import type { TimelineElement } from "@/types/timeline";
-
-// Only show in development
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-
-interface ActiveElement {
- element: TimelineElement;
- track: TimelineTrack;
- mediaItem: MediaItem | null;
-}
-
-export function DevelopmentDebug() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const { currentTime } = usePlaybackStore();
- const [showDebug, setShowDebug] = useState(false);
-
- // Don't render anything in production
- if (!SHOW_DEBUG_INFO) return null;
-
- // Get active elements at current time
- const getActiveElements = (): ActiveElement[] => {
- const activeElements: ActiveElement[] = [];
-
- tracks.forEach((track) => {
- track.elements.forEach((element) => {
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime >= elementStart && currentTime < elementEnd) {
- const mediaItem =
- element.type === "media"
- ? mediaItems.find((item) => item.id === element.mediaId) || null
- : null; // Text elements don't have media items
-
- activeElements.push({ element, track, mediaItem });
- }
- });
- });
-
- return activeElements;
- };
-
- const activeElements = getActiveElements();
-
- return (
- <div className="fixed bottom-4 right-4 z-50">
- <div className="flex flex-col items-end gap-2">
- {/* Toggle Button */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setShowDebug(!showDebug)}
- className="text-xs backdrop-blur-md bg-background/80 border-border/50"
- >
- Debug {showDebug ? "ON" : "OFF"}
- </Button>
-
- {/* Debug Info Panel */}
- {showDebug && (
- <div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
- <div className="text-xs font-medium mb-2 text-foreground">
- Active Elements ({activeElements.length})
- </div>
- <div className="space-y-1 max-h-40 overflow-y-auto">
- {activeElements.map((elementData, index) => (
- <div
- key={elementData.element.id}
- className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
- >
- <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
- {index + 1}
- </span>
- <div className="min-w-0 flex-1">
- <div className="truncate">{elementData.element.name}</div>
- <div className="text-muted-foreground text-[10px]">
- {elementData.element.type === "media"
- ? elementData.mediaItem?.type || "media"
- : "text"}
- </div>
- </div>
- </div>
- ))}
- {activeElements.length === 0 && (
- <div className="text-muted-foreground text-xs py-2 text-center">
- No active elements
- </div>
- )}
- </div>
- <div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
- Time: {currentTime.toFixed(2)}s
- </div>
- </div>
- )}
- </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 36478667..17893156 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -208,9 +208,8 @@ export function MediaView() {
<DragOverlay isVisible={isDragOver} />
<div className="p-3 pb-2">
- {/* Button to add/upload media */}
+ {/* Search and filter controls */}
<div className="flex gap-2">
- {/* Search and filter controls */}
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-full text-xs">
<SelectValue />
@@ -229,29 +228,6 @@ export function MediaView() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
-
- {/* Add media button */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
- >
- {isProcessing ? (
- <>
- <Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">{progress}%</span>
- </>
- ) : (
- <>
- <Plus className="h-4 w-4" />
- <span className="hidden sm:inline ml-2" aria-label="Add file">
- Add
- </span>
- </>
- )}
- </Button>
</div>
</div>
@@ -266,8 +242,32 @@ export function MediaView() {
No media in project
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
- Drag files here or use the button above
+ Drag files here or use the button below
</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center mt-2"
+ >
+ {isProcessing ? (
+ <>
+ <Upload className="h-4 w-4 animate-spin" />
+ <span className="hidden md:inline ml-2">{progress}%</span>
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4" />
+ <span
+ className="hidden sm:inline ml-2"
+ aria-label="Add file"
+ >
+ Add
+ </span>
+ </>
+ )}
+ </Button>
</div>
) : (
<div
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx
new file mode 100644
index 00000000..83a1f678
--- /dev/null
+++ b/apps/web/src/components/editor/snap-indicator.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+}: SnapIndicatorProps) {
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ // Get dynamic track labels width, fallback to 0 if no tracks or no ref
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+
+ const leftPosition =
+ trackLabelsWidth +
+ snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ className="absolute pointer-events-none z-[90]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px",
+ }}
+ >
+ <div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx
index ce4ed1ff..78aed3a8 100644
--- a/apps/web/src/components/editor/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline-playhead.tsx
@@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
@@ -34,6 +35,7 @@ export function TimelinePlayhead({
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
@@ -73,11 +75,15 @@ export function TimelinePlayhead({
}}
onMouseDown={handlePlayheadMouseDown}
>
- {/* The red line spanning full height */}
- <div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
+ {/* The playhead line spanning full height */}
+ <div
+ className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
+ />
- {/* Red dot indicator at the top (in ruler area) */}
- <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
+ {/* Playhead dot indicator at the top (in ruler area) */}
+ <div
+ className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
+ />
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index c3a16b9a..2be143eb 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -22,17 +22,21 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
+import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
export function TimelineTrackContent({
track,
zoomLevel,
+ onSnapPointChange,
}: {
track: TimelineTrack;
zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) {
const { mediaItems } = useMediaStore();
const {
tracks,
+ addTrack,
moveElementToTrack,
updateElementStartTime,
addElementToTrack,
@@ -44,8 +48,18 @@ export function TimelineTrackContent({
endDrag: endDragAction,
clearSelectedElements,
insertTrackAt,
+ snappingEnabled,
} = useTimelineStore();
+ const { currentTime } = usePlaybackStore();
+
+ // Initialize snapping hook
+ const { snapElementPosition } = useTimelineSnapping({
+ snapThreshold: 10,
+ enableElementSnapping: snappingEnabled,
+ enablePlayheadSnapping: snappingEnabled,
+ });
+
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
@@ -84,12 +98,34 @@ export function TimelineTrackContent({
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
- updateDragTime(snappedTime);
+ // Apply snapping if enabled
+ let finalTime = adjustedTime;
+ let snapPoint = null;
+ if (snappingEnabled) {
+ const snapResult = snapElementPosition(
+ adjustedTime,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined
+ );
+ finalTime = snapResult.snappedTime;
+ snapPoint = snapResult.snapPoint;
+
+ // Notify parent component about snap point change
+ onSnapPointChange?.(snapPoint);
+ } else {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ finalTime = snapTimeToFrame(adjustedTime, projectFps);
+
+ // Clear snap point when not snapping
+ onSnapPointChange?.(null);
+ }
+
+ updateDragTime(finalTime);
};
const handleMouseUp = (e: MouseEvent) => {
@@ -107,6 +143,8 @@ export function TimelineTrackContent({
dragState.currentTime
);
endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
}
return;
}
@@ -203,6 +241,8 @@ export function TimelineTrackContent({
if (isTrackThatStartedDrag) {
endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
}
};
@@ -228,6 +268,7 @@ export function TimelineTrackContent({
endDragAction,
selectedElements,
selectElement,
+ onSnapPointChange,
]);
const handleElementMouseDown = (
@@ -495,6 +536,16 @@ export function TimelineTrackContent({
e.preventDefault();
e.stopPropagation();
+ // Debug logging
+ console.log(
+ JSON.stringify({
+ message: "Drop event started in timeline track",
+ dataTransferTypes: Array.from(e.dataTransfer.types),
+ trackId: track.id,
+ trackType: track.type,
+ })
+ );
+
// Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
@@ -727,68 +778,28 @@ export function TimelineTrackContent({
// Handle position-aware track creation for media elements
if (!isCompatible || dropPosition !== "on") {
- const needsNewTrack = !isCompatible || dropPosition !== "on";
+ if (isVideoOrImage) {
+ // For video/image, check if we need a main track or additional media track
+ const mainTrack = getMainTrack(tracks);
- if (needsNewTrack) {
- if (isVideoOrImage) {
- // For video/image, check if we need a main track or additional media track
- const mainTrack = getMainTrack(tracks);
-
- if (!mainTrack) {
- // No main track exists, create it
- const updatedTracks = ensureMainTrack(tracks);
- const newMainTrack = getMainTrack(updatedTracks);
- if (newMainTrack && newMainTrack.elements.length === 0) {
- targetTrackId = newMainTrack.id;
- targetTrack = newMainTrack;
- } else {
- // Main track was created but somehow has elements, create new media track
- const mainTrackIndex = updatedTracks.findIndex(
- (t) => t.id === newMainTrack?.id
- );
- targetTrackId = insertTrackAt("media", mainTrackIndex);
- const updatedTracksAfterInsert =
- useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracksAfterInsert.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (
- mainTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- // Main track exists and is empty, use it
- targetTrackId = mainTrack.id;
- targetTrack = mainTrack;
- } else {
- // Create new media track above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
+ if (!mainTrack) {
+ // No main track exists, create it
+ targetTrackId = addTrack("media");
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ } else if (
+ mainTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
+ // Main track exists and is empty, use it
+ targetTrackId = mainTrack.id;
+ targetTrack = mainTrack;
+ } else {
+ // Create new media track
let insertIndex: number;
if (dropPosition === "above") {
@@ -796,18 +807,14 @@ export function TimelineTrackContent({
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
+ // Insert above main track
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
}
- targetTrackId = insertTrackAt("audio", insertIndex);
+ targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
@@ -815,6 +822,34 @@ export function TimelineTrackContent({
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
+ } else if (isAudio) {
+ // Audio tracks go at the bottom
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert after main track (bottom area)
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex + 1;
+ } else {
+ insertIndex = tracks.length; // Bottom of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("audio", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
}
}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 0926e7ba..ff9e0694 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -15,6 +15,8 @@ import {
Video,
Music,
TypeIcon,
+ Magnet,
+ Lock,
} from "lucide-react";
import {
Tooltip,
@@ -36,13 +38,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
import { TimelineTrackContent } from "./timeline-track";
import {
TimelinePlayhead,
@@ -50,6 +45,8 @@ import {
} from "./timeline-playhead";
import { SelectionBox } from "./selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import { addTextToNewTrack, addMediaToNewTrack } from "@/lib/timeline-utils";
import {
@@ -79,6 +76,9 @@ export function Timeline() {
separateAudio,
undo,
redo,
+ snappingEnabled,
+ toggleSnapping,
+ dragState,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
@@ -119,7 +119,7 @@ export function Timeline() {
const lastVerticalSync = useRef(0);
// Timeline playhead ruler handlers
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
+ const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
@@ -146,6 +146,18 @@ export function Timeline() {
},
});
+ // Calculate snap indicator state
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null
+ );
+ const showSnapIndicator =
+ dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
+
+ // Callback to handle snap point changes from TimelineTrackContent
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
@@ -642,136 +654,168 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
>
{/* Toolbar */}
- <div className="border-b flex items-center px-2 py-1 gap-1">
- <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 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}>
+ <Lock className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
</div>
{/* Timeline Container */}
@@ -791,6 +835,17 @@ export function Timeline() {
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
@@ -970,6 +1025,7 @@ export function Timeline() {
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
+ onSnapPointChange={handleSnapPointChange}
/>
</div>
</ContextMenuTrigger>
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 10f0e119..81505d4a 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -1,124 +1,140 @@
-"use client";
-
-import { motion } from "motion/react";
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
-import { getStars } from "@/lib/fetch-github-stars";
-import Image from "next/image";
-
-export function Footer() {
- 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();
- }, []);
-
- return (
- <motion.footer
- className="bg-background border-t"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ delay: 0.8, duration: 0.8 }}
- >
- <div className="max-w-5xl mx-auto px-8 py-10">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
- {/* Brand Section */}
- <div className="md:col-span-1 max-w-sm">
- <div className="flex items-center gap-2 mb-4">
- <Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
- <span className="font-bold text-lg">OpenCut</span>
- </div>
- <p className="text-sm text-muted-foreground mb-5">
- The open source video editor that gets the job done. Simple,
- powerful, and works on any platform.
- </p>
- <div className="flex gap-3">
- <Link
- href="https://github.com/OpenCut-app/OpenCut"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- <RiGithubLine className="h-5 w-5" />
- </Link>
- <Link
- href="https://x.com/OpenCutApp"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- <RiTwitterXLine className="h-5 w-5" />
- </Link>
- </div>
- </div>
-
- <div className="flex gap-12 justify-end items-start py-2">
- <div>
- <h3 className="font-semibold text-foreground mb-4">Resources</h3>
- <ul className="space-y-2 text-sm">
- <li>
- <Link
- href="/privacy"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Privacy policy
- </Link>
- </li>
- <li>
- <Link
- href="/terms"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Terms of use
- </Link>
- </li>
- </ul>
- </div>
-
- {/* Company Links */}
- <div>
- <h3 className="font-semibold text-foreground mb-4">Company</h3>
- <ul className="space-y-2 text-sm">
- <li>
- <Link
- href="/contributors"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Contributors
- </Link>
- </li>
- <li>
- <Link
- href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- About
- </Link>
- </li>
- </ul>
- </div>
- </div>
- </div>
-
- {/* Bottom Section */}
- <div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>© 2025 OpenCut, All Rights Reserved</span>
- </div>
- </div>
- </div>
- </motion.footer>
- );
-}
+"use client";
+
+import { motion } from "motion/react";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { RiDiscordFill, RiGithubLine, RiTwitterXLine } from "react-icons/ri";
+import { getStars } from "@/lib/fetch-github-stars";
+import Image from "next/image";
+
+export function Footer() {
+ 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();
+ }, []);
+
+ return (
+ <motion.footer
+ className="bg-background border-t"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ delay: 0.8, duration: 0.8 }}
+ >
+ <div className="max-w-5xl mx-auto px-8 py-10">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
+ {/* Brand Section */}
+ <div className="md:col-span-1 max-w-sm">
+ <div className="flex items-center gap-2 mb-4">
+ <Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
+ <span className="font-bold text-lg">OpenCut</span>
+ </div>
+ <p className="text-sm text-muted-foreground mb-5">
+ The open source video editor that gets the job done. Simple,
+ powerful, and works on any platform.
+ </p>
+ <div className="flex gap-3">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiGithubLine className="h-5 w-5" />
+ </Link>
+ <Link
+ href="https://x.com/OpenCutApp"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiTwitterXLine className="h-5 w-5" />
+ </Link>
+ <Link
+ href="https://discord.com/invite/Mu3acKZvCp"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiDiscordFill className="h-5 w-5" />
+ </Link>
+ </div>
+ </div>
+
+ <div className="flex gap-12 justify-end items-start py-2">
+ <div>
+ <h3 className="font-semibold text-foreground mb-4">Resources</h3>
+ <ul className="space-y-2 text-sm">
+ <li>
+ <Link
+ href="/roadmap"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Roadmap
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="/privacy"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Privacy policy
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="/terms"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Terms of use
+ </Link>
+ </li>
+ </ul>
+ </div>
+
+ {/* Company Links */}
+ <div>
+ <h3 className="font-semibold text-foreground mb-4">Company</h3>
+ <ul className="space-y-2 text-sm">
+ <li>
+ <Link
+ href="/contributors"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Contributors
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ About
+ </Link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ {/* Bottom Section */}
+ <div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>© 2025 OpenCut, All Rights Reserved</span>
+ </div>
+ </div>
+ </div>
+ </motion.footer>
+ );
+}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 776cc654..01ad7e7e 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -5,7 +5,7 @@ import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import { useState } from "react";
-import { useToast } from "@/hooks/use-toast";
+import { toast } from "sonner";
import Image from "next/image";
import { Handlebars } from "./handlebars";
@@ -17,16 +17,13 @@ interface HeroProps {
export function Hero({ signupCount }: HeroProps) {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
- const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
- toast({
- title: "Email required",
+ toast.error("Email required", {
description: "Please enter your email address.",
- variant: "destructive",
});
return;
}
@@ -45,25 +42,20 @@ export function Hero({ signupCount }: HeroProps) {
const data = (await response.json()) as { error: string };
if (response.ok) {
- toast({
- title: "Welcome to the waitlist! 🎉",
+ toast.success("Welcome to the waitlist! 🎉", {
description: "You'll be notified when we launch.",
});
setEmail("");
} else {
- toast({
- title: "Oops!",
+ toast.error("Oops!", {
description:
(data as { error: string }).error ||
"Something went wrong. Please try again.",
- variant: "destructive",
});
}
} catch (error) {
- toast({
- title: "Network error",
+ toast.error("Network error", {
description: "Please check your connection and try again.",
- variant: "destructive",
});
} finally {
setIsSubmitting(false);
diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx
index 8f3cb2e8..a4443996 100644
--- a/apps/web/src/components/ui/resizable.tsx
+++ b/apps/web/src/components/ui/resizable.tsx
@@ -1,6 +1,5 @@
"use client";
-import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "../../lib/utils";
@@ -29,7 +28,7 @@ const ResizableHandle = ({
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
- "relative w-0 bg-transparent cursor-col-resize data-[panel-group-direction=vertical]:h-0 data-[panel-group-direction=vertical]:cursor-row-resize",
+ "relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index a2fb0a01..0da023e6 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
new file mode 100644
index 00000000..8071107e
--- /dev/null
+++ b/apps/web/src/env.ts
@@ -0,0 +1,27 @@
+import { vercel } from "@t3-oss/env-core/presets-zod";
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+import { keys as auth } from "@opencut/auth/keys";
+import { keys as db } from "@opencut/db/keys";
+
+export const env = createEnv({
+ extends: [vercel(), auth(), db()],
+ server: {
+ ANALYZE: z.string().optional(),
+ // Added by Vercel
+ NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
+ NODE_ENV: z
+ .enum(["development", "production", "test"])
+ .default("development"),
+ UPSTASH_REDIS_REST_URL: z.string().url(),
+ UPSTASH_REDIS_REST_TOKEN: z.string(),
+ },
+ client: {},
+ runtimeEnv: {
+ ANALYZE: process.env.ANALYZE,
+ NEXT_RUNTIME: process.env.NEXT_RUNTIME,
+ NODE_ENV: process.env.NODE_ENV,
+ UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
+ UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
+ },
+});
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts
new file mode 100644
index 00000000..f7f96392
--- /dev/null
+++ b/apps/web/src/hooks/use-timeline-snapping.ts
@@ -0,0 +1,178 @@
+import { useCallback } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+export interface UseTimelineSnappingOptions {
+ snapThreshold?: number; // Distance in pixels to trigger snapping
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+}
+
+export function useTimelineSnapping({
+ snapThreshold = 10,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+}: UseTimelineSnappingOptions = {}) {
+ const findSnapPoints = useCallback(
+ (
+ tracks: TimelineTrack[],
+ currentTime: number,
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string
+ ): SnapPoint[] => {
+ const snapPoints: SnapPoint[] = [];
+
+ // Add element snap points
+ if (enableElementSnapping) {
+ tracks.forEach((track) => {
+ track.elements.forEach((element) => {
+ // Skip the element being dragged
+ if (element.id === excludeElementId) return;
+
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ snapPoints.push(
+ {
+ time: elementStart,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: elementEnd,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ }
+ );
+ });
+ });
+ }
+
+ // Add playhead snap point
+ if (enablePlayheadSnapping) {
+ snapPoints.push({
+ time: playheadTime,
+ type: "playhead",
+ });
+ }
+
+ return snapPoints;
+ },
+ [enableElementSnapping, enablePlayheadSnapping]
+ );
+
+ const snapToNearestPoint = useCallback(
+ (
+ targetTime: number,
+ snapPoints: SnapPoint[],
+ zoomLevel: number
+ ): SnapResult => {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ snapPoints.forEach((snapPoint) => {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ });
+
+ return {
+ snappedTime: closestSnapPoint
+ ? (closestSnapPoint as SnapPoint).time
+ : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+ },
+ [snapThreshold]
+ );
+
+ const snapElementPosition = useCallback(
+ (
+ targetTime: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId
+ );
+
+ return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
+ },
+ [findSnapPoints, snapToNearestPoint]
+ );
+
+ const snapElementEdge = useCallback(
+ (
+ targetTime: number,
+ elementDuration: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string,
+ snapToStart = true // true for start edge, false for end edge
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId
+ );
+
+ // For end edge snapping, we need to account for element duration
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+ const snapResult = snapToNearestPoint(
+ effectiveTargetTime,
+ snapPoints,
+ zoomLevel
+ );
+
+ // Adjust the snapped time back for end edge
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+ },
+ [findSnapPoints, snapToNearestPoint]
+ );
+
+ return {
+ snapElementPosition,
+ snapElementEdge,
+ findSnapPoints,
+ snapToNearestPoint,
+ };
+}
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index bf1cce02..92e78e74 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -1,10 +1,11 @@
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
+import { env } from "@/env";
const redis = new Redis({
- url: process.env.UPSTASH_REDIS_REST_URL!,
- token: process.env.UPSTASH_REDIS_REST_TOKEN!,
+ url: env.UPSTASH_REDIS_REST_URL,
+ token: env.UPSTASH_REDIS_REST_TOKEN,
});
export const waitlistRateLimit = new Ratelimit({
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index bdce5cc1..57e1e808 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import { env } from "./env";
export async function middleware(request: NextRequest) {
// Handle fuckcapcut.com domain redirect
@@ -9,7 +10,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
- if (path === "/editor" && process.env.NODE_ENV === "production") {
+ if (path === "/editor" && env.NODE_ENV === "production") {
const homeUrl = new URL("/", request.url);
homeUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(homeUrl);
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 37b45b4a..f6b9bfd8 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -42,6 +42,12 @@ interface TimelineStore {
// Manual method if you need to force recomputation
getSortedTracks: () => TimelineTrack[];
+ // Snapping settings
+ snappingEnabled: boolean;
+
+ // Snapping actions
+ toggleSnapping: () => void;
+
// Multi-selection
selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@@ -203,6 +209,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
redoStack: [],
selectedElements: [],
+ // Snapping settings defaults
+ snappingEnabled: true,
+
getSortedTracks: () => {
const { _tracks } = get();
const tracksWithMain = ensureMainTrack(_tracks);
@@ -949,5 +958,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateTracks(defaultTracks);
set({ history: [], redoStack: [], selectedElements: [] });
},
+
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
};
});