diff options
Diffstat (limited to 'apps/web/src')
90 files changed, 7019 insertions, 6870 deletions
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 55510dcb..5deef204 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@opencut/auth";
-import { toNextJsHandler } from "better-auth/next-js";
-
-export const { POST, GET } = toNextJsHandler(auth);
\ No newline at end of file +import { auth } from "@opencut/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts index e5c9fd94..9f00f221 100644 --- a/apps/web/src/app/api/waitlist/route.ts +++ b/apps/web/src/app/api/waitlist/route.ts @@ -1,83 +1,105 @@ -import { NextRequest, NextResponse } from "next/server";
-import { db, eq, waitlist } from "@opencut/db";
-import { checkBotId } from "botid/server";
-import { nanoid } from "nanoid";
-import { waitlistRateLimit } from "@/lib/rate-limit";
-import { z } from "zod";
-import { env } from "@/env";
-import { cookies } from "next/headers";
-import crypto from "crypto";
-
-const waitlistSchema = z.object({
- email: z.string().email("Invalid email format").min(1, "Email is required"),
-});
-
-const CSRF_TOKEN_NAME = "waitlist-csrf";
-const TOKEN_EXPIRY = 60 * 60 * 1000;
-
-async function validateCSRFToken(request: NextRequest): Promise<boolean> {
- const clientToken = request.headers.get("x-csrf-token");
- if (!clientToken) return false;
-
- const cookieStore = await cookies();
- const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
- if (!cookieValue) return false;
-
- const [token, timestamp, signature] = cookieValue.split(":");
- if (!token || !timestamp || !signature) return false;
-
- if (clientToken !== token) return false;
-
- const now = Date.now();
- const tokenTime = parseInt(timestamp);
- if (now - tokenTime > TOKEN_EXPIRY) return false;
-
- const expectedSignature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
-
- return signature === expectedSignature;
-}
-
-export async function POST(request: NextRequest) {
- const verification = await checkBotId();
-
- if (verification.isBot) {
- return NextResponse.json({ error: "Access denied" }, { status: 403 });
- }
-
- const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
- const { success } = await waitlistRateLimit.limit(identifier);
-
- if (!success) {
- return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
- }
- const isValidToken = await validateCSRFToken(request);
- if (!isValidToken) {
- return NextResponse.json({ error: "Invalid security token" }, { status: 403 });
- }
-
- try {
- const body = await request.json();
- const { email } = waitlistSchema.parse(body);
-
- const existingEmail = await db.select().from(waitlist).where(eq(waitlist.email, email.toLowerCase())).limit(1);
-
- if (existingEmail.length > 0) {
- return NextResponse.json({ error: "Email already registered" }, { status: 409 });
- }
-
- await db.insert(waitlist).values({
- id: nanoid(),
- email: email.toLowerCase(),
- });
-
- return NextResponse.json({ message: "Successfully joined waitlist!" }, { status: 201 });
- } catch (error) {
- if (error instanceof z.ZodError) {
- const firstError = error.errors[0];
- return NextResponse.json({ error: firstError.message }, { status: 400 });
- }
-
- console.error("Waitlist signup error:", error);
- return NextResponse.json({ error: "Internal server error" }, { status: 500 });
- }
-}
+import { NextRequest, NextResponse } from "next/server"; +import { db, eq, waitlist } from "@opencut/db"; +import { checkBotId } from "botid/server"; +import { nanoid } from "nanoid"; +import { waitlistRateLimit } from "@/lib/rate-limit"; +import { z } from "zod"; +import { env } from "@/env"; +import { cookies } from "next/headers"; +import crypto from "crypto"; + +const waitlistSchema = z.object({ + email: z.string().email("Invalid email format").min(1, "Email is required"), +}); + +const CSRF_TOKEN_NAME = "waitlist-csrf"; +const TOKEN_EXPIRY = 60 * 60 * 1000; + +async function validateCSRFToken(request: NextRequest): Promise<boolean> { + const clientToken = request.headers.get("x-csrf-token"); + if (!clientToken) return false; + + const cookieStore = await cookies(); + const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value; + if (!cookieValue) return false; + + const [token, timestamp, signature] = cookieValue.split(":"); + if (!token || !timestamp || !signature) return false; + + if (clientToken !== token) return false; + + const now = Date.now(); + const tokenTime = parseInt(timestamp); + if (now - tokenTime > TOKEN_EXPIRY) return false; + + const expectedSignature = crypto + .createHmac("sha256", env.BETTER_AUTH_SECRET) + .update(`${token}:${timestamp}`) + .digest("hex"); + + return signature === expectedSignature; +} + +export async function POST(request: NextRequest) { + const verification = await checkBotId(); + + if (verification.isBot) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; + const { success } = await waitlistRateLimit.limit(identifier); + + if (!success) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429 } + ); + } + const isValidToken = await validateCSRFToken(request); + if (!isValidToken) { + return NextResponse.json( + { error: "Invalid security token" }, + { status: 403 } + ); + } + + try { + const body = await request.json(); + const { email } = waitlistSchema.parse(body); + + const existingEmail = await db + .select() + .from(waitlist) + .where(eq(waitlist.email, email.toLowerCase())) + .limit(1); + + if (existingEmail.length > 0) { + return NextResponse.json( + { error: "Email already registered" }, + { status: 409 } + ); + } + + await db.insert(waitlist).values({ + id: nanoid(), + email: email.toLowerCase(), + }); + + return NextResponse.json( + { message: "Successfully joined waitlist!" }, + { status: 201 } + ); + } catch (error) { + if (error instanceof z.ZodError) { + const firstError = error.errors[0]; + return NextResponse.json({ error: firstError.message }, { status: 400 }); + } + + console.error("Waitlist signup error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts index 4c7695fa..fc2ff32c 100644 --- a/apps/web/src/app/api/waitlist/token/route.ts +++ b/apps/web/src/app/api/waitlist/token/route.ts @@ -5,7 +5,10 @@ import { env } from "@/env"; const CSRF_TOKEN_NAME = "waitlist-csrf"; const TOKEN_EXPIRY = 60 * 60 * 1000; -const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"]; +const allowedHosts = + env.NODE_ENV === "development" + ? ["localhost:3000", "127.0.0.1:3000"] + : ["opencut.app", "www.opencut.app"]; export async function GET(request: NextRequest) { const referer = request.headers.get("referer"); @@ -14,11 +17,20 @@ export async function GET(request: NextRequest) { if (referer) { const refererUrl = new URL(referer); - if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) { + if ( + !allowedHosts.some( + (allowed) => + refererUrl.host === allowed || refererUrl.host.endsWith(allowed) + ) + ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } } else if (host) { - if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) { + if ( + !allowedHosts.some( + (allowed) => host === allowed || host.endsWith(allowed) + ) + ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } } else { @@ -31,7 +43,10 @@ export async function GET(request: NextRequest) { const token = crypto.randomBytes(32).toString("hex"); const timestamp = Date.now(); - const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex"); + const signature = crypto + .createHmac("sha256", env.BETTER_AUTH_SECRET) + .update(`${token}:${timestamp}`) + .digest("hex"); const cookieStore = await cookies(); cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 5613f1f9..6a524866 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -1,277 +1,277 @@ -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 { Button } from "@/components/ui/button";
-import { ExternalLink } from "lucide-react";
-import Link from "next/link";
-import { GithubIcon } from "@/components/icons";
-import { Badge } from "@/components/ui/badge";
-
-export const metadata: Metadata = {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- type: "website",
- },
-};
-
-interface Contributor {
- id: number;
- login: string;
- avatar_url: string;
- html_url: string;
- contributions: number;
- type: string;
-}
-
-async function getContributors(): Promise<Contributor[]> {
- try {
- const response = await fetch(
- "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
- {
- headers: {
- Accept: "application/vnd.github.v3+json",
- "User-Agent": "OpenCut-Web-App",
- },
- next: { revalidate: 600 }, // 10 minutes
- }
- );
-
- if (!response.ok) {
- console.error("Failed to fetch contributors");
- return [];
- }
-
- const contributors = (await response.json()) as Contributor[];
-
- const filteredContributors = contributors.filter(
- (contributor: Contributor) => contributor.type === "User"
- );
-
- return filteredContributors;
- } catch (error) {
- console.error("Error fetching contributors:", error);
- return [];
- }
-}
-
-export default async function ContributorsPage() {
- const contributors = await getContributors();
- const topContributors = contributors.slice(0, 2);
- const otherContributors = contributors.slice(2);
-
- 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-6xl mx-auto">
- <div className="text-center mb-20">
- <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">
- Contributors
- </h1>
- <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
- Meet the amazing developers who are building the future of video
- editing
- </p>
-
- <div className="flex items-center justify-center gap-8 text-sm">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-foreground rounded-full" />
- <span className="font-medium">{contributors.length}</span>
- <span className="text-muted-foreground">contributors</span>
- </div>
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-foreground rounded-full" />
- <span className="font-medium">
- {contributors.reduce((sum, c) => sum + c.contributions, 0)}
- </span>
- <span className="text-muted-foreground">contributions</span>
- </div>
- </div>
- </div>
-
- {topContributors.length > 0 && (
- <div className="mb-20">
- <div className="text-center mb-12">
- <h2 className="text-2xl font-semibold mb-2">
- Top Contributors
- </h2>
- <p className="text-muted-foreground">
- Leading the way in contributions
- </p>
- </div>
-
- <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
- {topContributors.map((contributor, index) => (
- <Link
- key={contributor.id}
- href={contributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="group block flex-1"
- >
- <div className="relative mx-auto max-w-md">
- <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
- <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
- <CardContent className="p-8 text-center">
- <div className="relative mb-6">
- <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
- <AvatarImage
- src={contributor.avatar_url}
- alt={`${contributor.login}'s avatar`}
- />
- <AvatarFallback className="text-lg font-semibold">
- {contributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- </div>
- <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
- {contributor.login}
- </h3>
- <div className="flex items-center justify-center gap-2 text-muted-foreground">
- <span className="font-medium text-foreground">
- {contributor.contributions}
- </span>
- <span>contributions</span>
- </div>
- </CardContent>
- </Card>
- </div>
- </Link>
- ))}
- </div>
- </div>
- )}
-
- {otherContributors.length > 0 && (
- <div>
- <div className="text-center mb-12">
- <h2 className="text-2xl font-semibold mb-2">
- All Contributors
- </h2>
- <p className="text-muted-foreground">
- Everyone who makes OpenCut better
- </p>
- </div>
-
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
- {otherContributors.map((contributor, index) => (
- <Link
- key={contributor.id}
- href={contributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="group block"
- style={{
- animationDelay: `${index * 50}ms`,
- }}
- >
- <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
- <Avatar className="h-16 w-16 mx-auto mb-3">
- <AvatarImage
- src={contributor.avatar_url}
- alt={`${contributor.login}'s avatar`}
- />
- <AvatarFallback className="font-medium">
- {contributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <h3 className="font-medium text-sm truncate group-hover:text-foreground transition-colors mb-1">
- {contributor.login}
- </h3>
- <p className="text-xs text-muted-foreground">
- {contributor.contributions}
- </p>
- </div>
- </Link>
- ))}
- </div>
- </div>
- )}
-
- {contributors.length === 0 && (
- <div className="text-center py-20">
- <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted/50 flex items-center justify-center">
- <GithubIcon className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-xl font-medium mb-3">
- No contributors found
- </h3>
- <p className="text-muted-foreground mb-8 max-w-md mx-auto">
- Unable to load contributors at the moment. Check back later or
- view on GitHub.
- </p>
- <Link
- href="https://github.com/OpenCut-app/OpenCut/graphs/contributors"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Button variant="outline" className="gap-2">
- <GithubIcon className="h-4 w-4" />
- View on GitHub
- <ExternalLink className="h-4 w-4" />
- </Button>
- </Link>
- </div>
- )}
-
- <div className="mt-32 text-center">
- <div className="max-w-2xl mx-auto">
- <h2 className="text-3xl font-bold mb-4">Join the community</h2>
- <p className="text-lg text-muted-foreground mb-10 leading-relaxed">
- OpenCut is built by developers like you. Every contribution,
- no matter how small, helps make video editing more accessible
- for everyone.
- </p>
-
- <div className="flex flex-col sm:flex-row gap-4 justify-center">
- <Link
- href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Button size="lg" className="gap-2 group">
- <GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" />
- Start Contributing
- </Button>
- </Link>
- <Link
- href="https://github.com/OpenCut-app/OpenCut/issues"
- target="_blank"
- rel="noopener noreferrer"
- >
- <Button variant="outline" size="lg" className="gap-2 group">
- Browse Issues
- <ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" />
- </Button>
- </Link>
- </div>
- </div>
- </div>
- </div>
- </div>
- </main>
- </div>
- );
-}
+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 { Button } from "@/components/ui/button"; +import { ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { GithubIcon } from "@/components/icons"; +import { Badge } from "@/components/ui/badge"; + +export const metadata: Metadata = { + title: "Contributors - OpenCut", + description: + "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", + openGraph: { + title: "Contributors - OpenCut", + description: + "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", + type: "website", + }, +}; + +interface Contributor { + id: number; + login: string; + avatar_url: string; + html_url: string; + contributions: number; + type: string; +} + +async function getContributors(): Promise<Contributor[]> { + try { + const response = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCut-Web-App", + }, + next: { revalidate: 600 }, // 10 minutes + } + ); + + if (!response.ok) { + console.error("Failed to fetch contributors"); + return []; + } + + const contributors = (await response.json()) as Contributor[]; + + const filteredContributors = contributors.filter( + (contributor: Contributor) => contributor.type === "User" + ); + + return filteredContributors; + } catch (error) { + console.error("Error fetching contributors:", error); + return []; + } +} + +export default async function ContributorsPage() { + const contributors = await getContributors(); + const topContributors = contributors.slice(0, 2); + const otherContributors = contributors.slice(2); + + 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-6xl mx-auto"> + <div className="text-center mb-20"> + <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"> + Contributors + </h1> + <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> + Meet the amazing developers who are building the future of video + editing + </p> + + <div className="flex items-center justify-center gap-8 text-sm"> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 bg-foreground rounded-full" /> + <span className="font-medium">{contributors.length}</span> + <span className="text-muted-foreground">contributors</span> + </div> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 bg-foreground rounded-full" /> + <span className="font-medium"> + {contributors.reduce((sum, c) => sum + c.contributions, 0)} + </span> + <span className="text-muted-foreground">contributions</span> + </div> + </div> + </div> + + {topContributors.length > 0 && ( + <div className="mb-20"> + <div className="text-center mb-12"> + <h2 className="text-2xl font-semibold mb-2"> + Top Contributors + </h2> + <p className="text-muted-foreground"> + Leading the way in contributions + </p> + </div> + + <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto"> + {topContributors.map((contributor, index) => ( + <Link + key={contributor.id} + href={contributor.html_url} + target="_blank" + rel="noopener noreferrer" + className="group block flex-1" + > + <div className="relative mx-auto max-w-md"> + <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" /> + <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> + <CardContent className="p-8 text-center"> + <div className="relative mb-6"> + <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> + <AvatarImage + src={contributor.avatar_url} + alt={`${contributor.login}'s avatar`} + /> + <AvatarFallback className="text-lg font-semibold"> + {contributor.login.charAt(0).toUpperCase()} + </AvatarFallback> + </Avatar> + </div> + <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> + {contributor.login} + </h3> + <div className="flex items-center justify-center gap-2 text-muted-foreground"> + <span className="font-medium text-foreground"> + {contributor.contributions} + </span> + <span>contributions</span> + </div> + </CardContent> + </Card> + </div> + </Link> + ))} + </div> + </div> + )} + + {otherContributors.length > 0 && ( + <div> + <div className="text-center mb-12"> + <h2 className="text-2xl font-semibold mb-2"> + All Contributors + </h2> + <p className="text-muted-foreground"> + Everyone who makes OpenCut better + </p> + </div> + + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6"> + {otherContributors.map((contributor, index) => ( + <Link + key={contributor.id} + href={contributor.html_url} + target="_blank" + rel="noopener noreferrer" + className="group block" + style={{ + animationDelay: `${index * 50}ms`, + }} + > + <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50"> + <Avatar className="h-16 w-16 mx-auto mb-3"> + <AvatarImage + src={contributor.avatar_url} + alt={`${contributor.login}'s avatar`} + /> + <AvatarFallback className="font-medium"> + {contributor.login.charAt(0).toUpperCase()} + </AvatarFallback> + </Avatar> + <h3 className="font-medium text-sm truncate group-hover:text-foreground transition-colors mb-1"> + {contributor.login} + </h3> + <p className="text-xs text-muted-foreground"> + {contributor.contributions} + </p> + </div> + </Link> + ))} + </div> + </div> + )} + + {contributors.length === 0 && ( + <div className="text-center py-20"> + <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted/50 flex items-center justify-center"> + <GithubIcon className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-xl font-medium mb-3"> + No contributors found + </h3> + <p className="text-muted-foreground mb-8 max-w-md mx-auto"> + Unable to load contributors at the moment. Check back later or + view on GitHub. + </p> + <Link + href="https://github.com/OpenCut-app/OpenCut/graphs/contributors" + target="_blank" + rel="noopener noreferrer" + > + <Button variant="outline" className="gap-2"> + <GithubIcon className="h-4 w-4" /> + View on GitHub + <ExternalLink className="h-4 w-4" /> + </Button> + </Link> + </div> + )} + + <div className="mt-32 text-center"> + <div className="max-w-2xl mx-auto"> + <h2 className="text-3xl font-bold mb-4">Join the community</h2> + <p className="text-lg text-muted-foreground mb-10 leading-relaxed"> + OpenCut is built by developers like you. Every contribution, + no matter how small, helps make video editing more accessible + for everyone. + </p> + + <div className="flex flex-col sm:flex-row gap-4 justify-center"> + <Link + href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md" + target="_blank" + rel="noopener noreferrer" + > + <Button size="lg" className="gap-2 group"> + <GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" /> + Start Contributing + </Button> + </Link> + <Link + href="https://github.com/OpenCut-app/OpenCut/issues" + target="_blank" + rel="noopener noreferrer" + > + <Button variant="outline" size="lg" className="gap-2 group"> + Browse Issues + <ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" /> + </Button> + </Link> + </div> + </div> + </div> + </div> + </div> + </main> + </div> + ); +} diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts index de8cf6f0..0608974d 100644 --- a/apps/web/src/app/metadata.ts +++ b/apps/web/src/app/metadata.ts @@ -1,24 +1,15 @@ -import { Metadata } from "next"; - -export const SITE_INFO = { - title: "OpenCut", - description: - "A simple but powerful video editor that gets the job done. In your browser.", - url: "https://opencut.app", - openGraphImage: "https://opencut.app/open-graph/default.jpg", - twitterImage: "/open-graph/default.jpg", - favicon: "/favicon.ico", -}; +import type { Metadata } from "next"; +import { SITE_INFO, SITE_URL } from "@/constants/site"; export const baseMetaData: Metadata = { - metadataBase: new URL("https://opencut.app"), + metadataBase: new URL(SITE_URL), title: SITE_INFO.title, description: SITE_INFO.description, openGraph: { title: SITE_INFO.title, description: SITE_INFO.description, - url: "https://opencut.app", - siteName: "OpenCut", + url: SITE_URL, + siteName: SITE_INFO.title, locale: "en_US", type: "website", images: [ @@ -26,7 +17,7 @@ export const baseMetaData: Metadata = { url: SITE_INFO.openGraphImage, width: 1200, height: 630, - alt: "OpenCut", + alt: "OpenCut Wordmark", }, ], }, diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 74961549..818884ed 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,6 +1,14 @@ import { Hero } from "@/components/landing/hero"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; +import type { Metadata } from "next"; +import { SITE_URL } from "@/constants/site"; + +export const metadata: Metadata = { + alternates: { + canonical: SITE_URL, + }, +}; export default async function Home() { return ( diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 42dd0f9a..b7d700b5 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -1,253 +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>
- );
-}
+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 index c62dce3a..5c003cf0 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -1,247 +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>
- );
-}
+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/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts index b16bd856..ab645655 100644 --- a/apps/web/src/app/rss.xml/route.ts +++ b/apps/web/src/app/rss.xml/route.ts @@ -1,33 +1,33 @@ -import { Feed } from 'feed'; -import { getPosts } from '@/lib/blog-query'; -import { SITE_INFO } from '../metadata'; +import { Feed } from "feed"; +import { getPosts } from "@/lib/blog-query"; +import { SITE_INFO, SITE_URL } from "@/constants/site"; export async function GET() { try { const { posts } = await getPosts(); - + const feed = new Feed({ - title: 'OpenCut Blog', + title: `${SITE_INFO.title} Blog`, description: SITE_INFO.description, - id: `${SITE_INFO.url}`, - link: `${SITE_INFO.url}/blog/`, - language: 'en', + id: `${SITE_URL}`, + link: `${SITE_URL}/blog/`, + language: "en", image: `${SITE_INFO.openGraphImage}`, favicon: `${SITE_INFO.favicon}`, copyright: `All rights reserved ${new Date().getFullYear()}, ${ SITE_INFO.title }`, }); - + for (const post of posts) { feed.addItem({ - title: post.title, - id: `${SITE_INFO.url}/blog/${post.slug}`, - link: `${SITE_INFO.url}/blog/${post.slug}`, - description: post.description, - author: post.authors.map((author) => ({ - name: author.name, - })), + title: post.title, + id: `${SITE_URL}/blog/${post.slug}`, + link: `${SITE_URL}/blog/${post.slug}`, + description: post.description, + author: post.authors.map((author) => ({ + name: author.name, + })), date: new Date(post.publishedAt), image: post.coverImage || SITE_INFO.openGraphImage, }); @@ -35,12 +35,12 @@ export async function GET() { return new Response(feed.rss2(), { headers: { - 'Content-Type': 'text/xml', - 'Cache-Control': 'public, max-age=86400, stale-while-revalidate', + "Content-Type": "text/xml", + "Cache-Control": "public, max-age=86400, stale-while-revalidate", }, }); } catch (error) { - console.error('Error generating RSS feed', error); - return new Response('Internal Server Error', { status: 500 }); + console.error("Error generating RSS feed", error); + return new Response("Internal Server Error", { status: 500 }); } -}
\ No newline at end of file +} diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index c56e8fc1..7d1fda7f 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -1,317 +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>
- );
-}
+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/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx index 56b3d27a..7fe6df66 100644 --- a/apps/web/src/app/why-not-capcut/page.tsx +++ b/apps/web/src/app/why-not-capcut/page.tsx @@ -1,191 +1,191 @@ -import { Header } from "@/components/header";
-
-export default function WhyNotCapcut() {
- return (
- <div className="min-h-screen bg-background px-5">
- <Header />
-
- <main className="relative mt-12">
- <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-6xl mx-auto">
- <div className="text-center mb-20">
- <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
- Fuck CapCut
- </h1>
- <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
- Roasting time, so get ready motherfucker.
- </p>
- </div>
-
- <div className="max-w-4xl mx-auto space-y-12">
- <div>
- <h2 className="text-3xl font-bold mb-6">
- Seriously, what the fuck else do you want?
- </h2>
- <p className="text-lg mb-6">
- You probably use CapCut and think your video editing is
- special. You think your fucking TikTok with 47 transitions and
- 12 different fonts is going to get you some viral fame. You
- think loading up every goddamn effect in their library makes
- your content better. Wrong, motherfucker. Let me describe what
- CapCut actually gives you:
- </p>
- <ul className="text-lg space-y-2 mb-6 list-disc list-inside">
- <li>A paywall every time you breathe</li>
- <li>Terms of service that steal your shit</li>
- <li>
- More "Get Pro" dialogs than a Windows 95 error message
- </li>
- <li>
- Features that disappear behind paywalls while you're fucking
- using them
- </li>
- <li>Bugs disguised as "premium features"</li>
- </ul>
- <p className="text-lg mb-6">
- <strong>Well guess what, motherfucker:</strong>
- </p>
- <p className="text-lg mb-6">
- You. Are. Getting. Scammed. Look at this shit. It's a fucking
- video editor. Why the fuck do you need to pay $20/month just
- to remove a goddamn watermark? You spent hours editing your
- video and they slap their logo on it like they fucking made
- it.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- The "Get Pro" dialog is everywhere
- </h2>
- <p className="text-lg mb-6">
- This motherfucking dialog pops up more than ads on a pirated
- movie site. Want to add a transition? Get Pro. Want to export
- without their watermark? Get Pro. Want to use more than 2
- fonts? Get fucking Pro, peasant.
- </p>
- <p className="text-lg mb-6">
- Did you seriously think you could edit a video without seeing
- this dialog 47 times? You click one button and BAM - there it
- is again, asking for your credit card like a desperate ex
- asking for money.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- Everything costs money now
- </h2>
- <p className="text-lg mb-6">
- You dumbass. You thought CapCut was free, but no. Free means
- they let you open the app. Everything else costs money. Basic
- shake effect? That'll be $20/month. A decent transition that isn't
- "fade"? Pay up, motherfucker.
- </p>
- <p className="text-lg mb-6">
- Here's my favorite piece of bullshit: You import an MP3 file -
- you know, AUDIO - and try to export. "Sorry, can't export
- because you're using our premium extract audio feature!"
- </p>
- <p className="text-lg mb-6">
- <strong>
- My MP3 was already fucking audio, you absolute morons.
- </strong>
- </p>
- <p className="text-lg mb-6">
- But wait, there's more! If you drag that same MP3 to their
- media panel first, then to the timeline, it magically works.
- This isn't a bug, it's a fucking scam disguised as software
- engineering.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- Their Terms of Service are insane
- </h2>
- <p className="text-lg mb-6">
- Look at this shit. You upload your content and they basically
- say "thanks for the free content, we own it now, but if Disney
- sues anyone, that's your problem."
- </p>
- <p className="text-lg mb-6">
- <strong>CapCut's Terms of Service:</strong> We get full rights
- to use, modify, distribute, and monetize everything you upload
- - permanently and without paying you shit. But you're still
- responsible if anything goes wrong.
- </p>
- <p className="text-lg mb-6">
- Translation: "We'll make money off your viral video, you
- handle the lawsuits." Brilliant legal strategy, you fucks.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- The editor is actually good
- </h2>
- <p className="text-lg mb-6">
- Here's the thing that makes me want to punch my monitor: the
- actual video editor is fucking good. It's intuitive, powerful,
- and anyone can figure it out. When it's not begging for money
- every 30 seconds, it actually works well.
- </p>
- <p className="text-lg mb-6">
- Which makes everything else so much worse. They built
- something people want to use, then turned it into a digital
- slot machine. Every click might trigger a payment request.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- This is a video editor. Look at it. You've never seen one
- before.
- </h2>
- <p className="text-lg mb-6">
- Like the person who's never used software that doesn't
- constantly beg for money, you have no fucking idea what a
- video editor should be. All you've ever seen are predatory
- apps disguised as creative tools.
- </p>
- <p className="text-lg mb-6">
- A real video editor lets you edit videos. It doesn't steal
- your content. It doesn't pop up payment dialogs every 5
- seconds. It doesn't charge you separately for basic features
- that should be free.
- </p>
- </div>
-
- <div>
- <h2 className="text-3xl font-bold mb-6">
- Yes, this is fucking satire, you fuck
- </h2>
- <p className="text-lg mb-6">
- I'm not actually saying all video editors should be basic as
- shit. What I'm saying is that all the problems we have with
- video editing apps are{" "}
- <strong>ones they create themselves</strong>. Video editors
- aren't broken by default - they edit videos, export them, and
- let you use basic features without constantly begging for
- money. CapCut breaks them. They turn them into payment
- processors with video editing as a side feature.
- </p>
- <p className="text-lg">
- <em>"Good software gets out of your way."</em>
- <br />- Some smart motherfucker who definitely wasn't working
- at CapCut
- </p>
- </div>
- </div>
- </div>
- </div>
- </main>
- </div>
- );
-}
+import { Header } from "@/components/header"; + +export default function WhyNotCapcut() { + return ( + <div className="min-h-screen bg-background px-5"> + <Header /> + + <main className="relative mt-12"> + <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-6xl mx-auto"> + <div className="text-center mb-20"> + <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> + Fuck CapCut + </h1> + <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> + Roasting time, so get ready motherfucker. + </p> + </div> + + <div className="max-w-4xl mx-auto space-y-12"> + <div> + <h2 className="text-3xl font-bold mb-6"> + Seriously, what the fuck else do you want? + </h2> + <p className="text-lg mb-6"> + You probably use CapCut and think your video editing is + special. You think your fucking TikTok with 47 transitions and + 12 different fonts is going to get you some viral fame. You + think loading up every goddamn effect in their library makes + your content better. Wrong, motherfucker. Let me describe what + CapCut actually gives you: + </p> + <ul className="text-lg space-y-2 mb-6 list-disc list-inside"> + <li>A paywall every time you breathe</li> + <li>Terms of service that steal your shit</li> + <li> + More "Get Pro" dialogs than a Windows 95 error message + </li> + <li> + Features that disappear behind paywalls while you're fucking + using them + </li> + <li>Bugs disguised as "premium features"</li> + </ul> + <p className="text-lg mb-6"> + <strong>Well guess what, motherfucker:</strong> + </p> + <p className="text-lg mb-6"> + You. Are. Getting. Scammed. Look at this shit. It's a fucking + video editor. Why the fuck do you need to pay $20/month just + to remove a goddamn watermark? You spent hours editing your + video and they slap their logo on it like they fucking made + it. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + The "Get Pro" dialog is everywhere + </h2> + <p className="text-lg mb-6"> + This motherfucking dialog pops up more than ads on a pirated + movie site. Want to add a transition? Get Pro. Want to export + without their watermark? Get Pro. Want to use more than 2 + fonts? Get fucking Pro, peasant. + </p> + <p className="text-lg mb-6"> + Did you seriously think you could edit a video without seeing + this dialog 47 times? You click one button and BAM - there it + is again, asking for your credit card like a desperate ex + asking for money. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + Everything costs money now + </h2> + <p className="text-lg mb-6"> + You dumbass. You thought CapCut was free, but no. Free means + they let you open the app. Everything else costs money. Basic + shake effect? That'll be $20/month. A decent transition that + isn't "fade"? Pay up, motherfucker. + </p> + <p className="text-lg mb-6"> + Here's my favorite piece of bullshit: You import an MP3 file - + you know, AUDIO - and try to export. "Sorry, can't export + because you're using our premium extract audio feature!" + </p> + <p className="text-lg mb-6"> + <strong> + My MP3 was already fucking audio, you absolute morons. + </strong> + </p> + <p className="text-lg mb-6"> + But wait, there's more! If you drag that same MP3 to their + media panel first, then to the timeline, it magically works. + This isn't a bug, it's a fucking scam disguised as software + engineering. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + Their Terms of Service are insane + </h2> + <p className="text-lg mb-6"> + Look at this shit. You upload your content and they basically + say "thanks for the free content, we own it now, but if Disney + sues anyone, that's your problem." + </p> + <p className="text-lg mb-6"> + <strong>CapCut's Terms of Service:</strong> We get full rights + to use, modify, distribute, and monetize everything you upload + - permanently and without paying you shit. But you're still + responsible if anything goes wrong. + </p> + <p className="text-lg mb-6"> + Translation: "We'll make money off your viral video, you + handle the lawsuits." Brilliant legal strategy, you fucks. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + The editor is actually good + </h2> + <p className="text-lg mb-6"> + Here's the thing that makes me want to punch my monitor: the + actual video editor is fucking good. It's intuitive, powerful, + and anyone can figure it out. When it's not begging for money + every 30 seconds, it actually works well. + </p> + <p className="text-lg mb-6"> + Which makes everything else so much worse. They built + something people want to use, then turned it into a digital + slot machine. Every click might trigger a payment request. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + This is a video editor. Look at it. You've never seen one + before. + </h2> + <p className="text-lg mb-6"> + Like the person who's never used software that doesn't + constantly beg for money, you have no fucking idea what a + video editor should be. All you've ever seen are predatory + apps disguised as creative tools. + </p> + <p className="text-lg mb-6"> + A real video editor lets you edit videos. It doesn't steal + your content. It doesn't pop up payment dialogs every 5 + seconds. It doesn't charge you separately for basic features + that should be free. + </p> + </div> + + <div> + <h2 className="text-3xl font-bold mb-6"> + Yes, this is fucking satire, you fuck + </h2> + <p className="text-lg mb-6"> + I'm not actually saying all video editors should be basic as + shit. What I'm saying is that all the problems we have with + video editing apps are{" "} + <strong>ones they create themselves</strong>. Video editors + aren't broken by default - they edit videos, export them, and + let you use basic features without constantly begging for + money. CapCut breaks them. They turn them into payment + processors with video editing as a side feature. + </p> + <p className="text-lg"> + <em>"Good software gets out of your way."</em> + <br />- Some smart motherfucker who definitely wasn't working + at CapCut + </p> + </div> + </div> + </div> + </div> + </main> + </div> + ); +} diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index 721564ea..a640dc4c 100644 --- a/apps/web/src/components/background-settings.tsx +++ b/apps/web/src/components/background-settings.tsx @@ -1,184 +1,184 @@ -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
-import { Button } from "./ui/button";
-import { BackgroundIcon } from "./icons";
-import { cn } from "@/lib/utils";
-import Image from "next/image";
-import { colors } from "@/data/colors";
-import { useProjectStore } from "@/stores/project-store";
-import { PipetteIcon } from "lucide-react";
-
-type BackgroundTab = "color" | "blur";
-
-export function BackgroundSettings() {
- const { activeProject, updateBackgroundType } = useProjectStore();
-
- // ✅ Good: derive activeTab from activeProject during rendering
- const activeTab = activeProject?.backgroundType || "color";
-
- const handleColorSelect = (color: string) => {
- updateBackgroundType("color", { backgroundColor: color });
- };
-
- const handleBlurSelect = (blurIntensity: number) => {
- updateBackgroundType("blur", { blurIntensity });
- };
-
- const tabs = [
- {
- label: "Color",
- value: "color",
- },
- {
- label: "Blur",
- value: "blur",
- },
- ];
-
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="text"
- size="icon"
- className="!size-4 border border-muted-foreground"
- >
- <BackgroundIcon className="!size-3" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
- <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
- <h2 className="text-sm">Background</h2>
- <div className="flex items-center gap-2 text-sm">
- {tabs.map((tab) => (
- <span
- key={tab.value}
- onClick={() => {
- // Switch to the background type when clicking tabs
- if (tab.value === "color") {
- updateBackgroundType("color", {
- backgroundColor:
- activeProject?.backgroundColor || "#000000",
- });
- } else {
- updateBackgroundType("blur", {
- blurIntensity: activeProject?.blurIntensity || 8,
- });
- }
- }}
- className={cn(
- "text-muted-foreground cursor-pointer",
- activeTab === tab.value && "text-foreground"
- )}
- >
- {tab.label}
- </span>
- ))}
- </div>
- </div>
- {activeTab === "color" ? (
- <ColorView
- selectedColor={activeProject?.backgroundColor || "#000000"}
- onColorSelect={handleColorSelect}
- />
- ) : (
- <BlurView
- selectedBlur={activeProject?.blurIntensity || 8}
- onBlurSelect={handleBlurSelect}
- />
- )}
- </PopoverContent>
- </Popover>
- );
-}
-
-function ColorView({
- selectedColor,
- onColorSelect,
-}: {
- selectedColor: string;
- onColorSelect: (color: string) => void;
-}) {
- return (
- <div className="w-full h-full">
- <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
- <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
- <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
- <PipetteIcon className="size-4" />
- </div>
- {colors.map((color) => (
- <ColorItem
- key={color}
- color={color}
- isSelected={color === selectedColor}
- onClick={() => onColorSelect(color)}
- />
- ))}
- </div>
- </div>
- );
-}
-
-function ColorItem({
- color,
- isSelected,
- onClick,
-}: {
- color: string;
- isSelected: boolean;
- onClick: () => void;
-}) {
- return (
- <div
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
- isSelected && "border-2 border-primary"
- )}
- style={{ backgroundColor: color }}
- onClick={onClick}
- />
- );
-}
-
-function BlurView({
- selectedBlur,
- onBlurSelect,
-}: {
- selectedBlur: number;
- onBlurSelect: (blurIntensity: number) => void;
-}) {
- const blurLevels = [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
- ];
- const blurImage =
- "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
-
- return (
- <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
- {blurLevels.map((blur) => (
- <div
- key={blur.value}
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
- selectedBlur === blur.value && "border-2 border-primary"
- )}
- onClick={() => onBlurSelect(blur.value)}
- >
- <Image
- src={blurImage}
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- />
- <div className="absolute bottom-1 left-1 right-1 text-center">
- <span className="text-xs text-white bg-black/50 px-1 rounded">
- {blur.label}
- </span>
- </div>
- </div>
- ))}
- </div>
- );
-}
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Button } from "./ui/button"; +import { BackgroundIcon } from "./icons"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { colors } from "@/data/colors"; +import { useProjectStore } from "@/stores/project-store"; +import { PipetteIcon } from "lucide-react"; + +type BackgroundTab = "color" | "blur"; + +export function BackgroundSettings() { + const { activeProject, updateBackgroundType } = useProjectStore(); + + // ✅ Good: derive activeTab from activeProject during rendering + const activeTab = activeProject?.backgroundType || "color"; + + const handleColorSelect = (color: string) => { + updateBackgroundType("color", { backgroundColor: color }); + }; + + const handleBlurSelect = (blurIntensity: number) => { + updateBackgroundType("blur", { blurIntensity }); + }; + + const tabs = [ + { + label: "Color", + value: "color", + }, + { + label: "Blur", + value: "blur", + }, + ]; + + return ( + <Popover> + <PopoverTrigger asChild> + <Button + variant="text" + size="icon" + className="!size-4 border border-muted-foreground" + > + <BackgroundIcon className="!size-3" /> + </Button> + </PopoverTrigger> + <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0"> + <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3"> + <h2 className="text-sm">Background</h2> + <div className="flex items-center gap-2 text-sm"> + {tabs.map((tab) => ( + <span + key={tab.value} + onClick={() => { + // Switch to the background type when clicking tabs + if (tab.value === "color") { + updateBackgroundType("color", { + backgroundColor: + activeProject?.backgroundColor || "#000000", + }); + } else { + updateBackgroundType("blur", { + blurIntensity: activeProject?.blurIntensity || 8, + }); + } + }} + className={cn( + "text-muted-foreground cursor-pointer", + activeTab === tab.value && "text-foreground" + )} + > + {tab.label} + </span> + ))} + </div> + </div> + {activeTab === "color" ? ( + <ColorView + selectedColor={activeProject?.backgroundColor || "#000000"} + onColorSelect={handleColorSelect} + /> + ) : ( + <BlurView + selectedBlur={activeProject?.blurIntensity || 8} + onBlurSelect={handleBlurSelect} + /> + )} + </PopoverContent> + </Popover> + ); +} + +function ColorView({ + selectedColor, + onColorSelect, +}: { + selectedColor: string; + onColorSelect: (color: string) => void; +}) { + return ( + <div className="w-full h-full"> + <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none" /> + <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto"> + <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center"> + <PipetteIcon className="size-4" /> + </div> + {colors.map((color) => ( + <ColorItem + key={color} + color={color} + isSelected={color === selectedColor} + onClick={() => onColorSelect(color)} + /> + ))} + </div> + </div> + ); +} + +function ColorItem({ + color, + isSelected, + onClick, +}: { + color: string; + isSelected: boolean; + onClick: () => void; +}) { + return ( + <div + className={cn( + "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary", + isSelected && "border-2 border-primary" + )} + style={{ backgroundColor: color }} + onClick={onClick} + /> + ); +} + +function BlurView({ + selectedBlur, + onBlurSelect, +}: { + selectedBlur: number; + onBlurSelect: (blurIntensity: number) => void; +}) { + const blurLevels = [ + { label: "Light", value: 4 }, + { label: "Medium", value: 8 }, + { label: "Heavy", value: 18 }, + ]; + const blurImage = + "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + + return ( + <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0"> + {blurLevels.map((blur) => ( + <div + key={blur.value} + className={cn( + "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden", + selectedBlur === blur.value && "border-2 border-primary" + )} + onClick={() => onBlurSelect(blur.value)} + > + <Image + src={blurImage} + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + /> + <div className="absolute bottom-1 left-1 right-1 text-center"> + <span className="text-xs text-white bg-black/50 px-1 rounded"> + {blur.label} + </span> + </div> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index e7df2896..9285398e 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,142 +1,141 @@ -"use client";
-
-import { Download, SquarePen } from "lucide-react";
-import Link from "next/link";
-import { type MouseEvent, useRef, useState } from "react";
-import { Input } from "@/components/ui/input";
-import { formatTimeCode } from "@/lib/time";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { HeaderBase } from "./header-base";
-import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbSeparator,
-} from "./ui/breadcrumb";
-import { Button } from "./ui/button";
-
-export function EditorHeader() {
- const { getTotalDuration } = useTimelineStore();
- const { activeProject, renameProject } = useProjectStore();
- const [isEditing, setIsEditing] = useState(false);
- const [newName, setNewName] = useState(activeProject?.name || "");
- const inputRef = useRef<HTMLInputElement>(null);
-
- const handleExport = () => {
- // TODO: Implement export functionality
- // NOTE: This is already being worked on
- console.log("Export project");
- };
-
- const handleNameEdit = (e: MouseEvent<HTMLButtonElement>) => {
- if (!activeProject) return;
- e.stopPropagation();
- setIsEditing(true);
- setNewName(activeProject.name);
- };
-
- const handleNameSave = async () => {
- if (activeProject && newName.trim() && newName !== activeProject.name) {
- try {
- await renameProject(activeProject.id, newName.trim());
- } catch (error) {
- console.error("Failed to rename project:", error);
- setNewName(activeProject.name);
- }
- }
- setIsEditing(false);
- };
-
- const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
- if (e.key === "Enter") handleNameSave();
- else if (e.key === "Escape") setIsEditing(false);
- };
-
- const leftContent = (
- <div className="flex items-center gap-2">
- <Breadcrumb>
- <BreadcrumbList className="text-current">
- <BreadcrumbItem>
- <BreadcrumbLink asChild>
- <Link
- href="/projects"
- className="hover:text-muted-foreground inline-flex items-center"
- >
- All Projects
- </Link>
- </BreadcrumbLink>
- </BreadcrumbItem>
- <BreadcrumbSeparator />
- <BreadcrumbItem>
- {isEditing ? (
- <Input
- ref={inputRef}
- className="text-sm font-medium w-40 xl:w-60 px-2 py-1 h-9 truncate"
- value={newName}
- onChange={(e) => setNewName(e.target.value)}
- onBlur={handleNameSave}
- onKeyDown={handleInputKeyDown}
- onFocus={(e) => e.target.select()}
- maxLength={64}
- aria-label="Project name"
- autoFocus
- />
- ) : (
- <Button
- className="text-sm font-medium cursor-pointer hover:text-muted-foreground flex items-center gap-2 group"
- title="Click to rename"
- variant="text"
- tabIndex={0}
- onClick={handleNameEdit}
- >
- <div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60">
- {activeProject?.name}
- </div>
- <SquarePen className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
- </Button>
- )}
- </BreadcrumbItem>
- </BreadcrumbList>
- </Breadcrumb>
- </div>
- );
-
- const centerContent = (
- <div className="flex items-center justify-center gap-2 text-xs">
- <span>
- {formatTimeCode(
- getTotalDuration(),
- "HH:MM:SS:FF",
- activeProject?.fps || 30,
- )}
- </span>
- </div>
- );
-
- const rightContent = (
- <nav className="flex items-center justify-end gap-2">
- <KeyboardShortcutsHelp />
- <Button
- size="sm"
- variant="primary"
- className="h-7 text-xs"
- onClick={handleExport}
- >
- <Download className="h-4 w-4" />
- <span className="text-sm">Export</span>
- </Button>
- </nav>
- );
-
- return (
- <HeaderBase
- leftContent={leftContent}
- centerContent={centerContent}
- rightContent={rightContent}
- className="bg-background h-[3.2rem] px-4 items-center grid grid-cols-3"
- />
- );
-}
+"use client"; + +import Link from "next/link"; +import { Button } from "./ui/button"; +import { SquarePen, Download } from "lucide-react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { HeaderBase } from "./header-base"; +import { formatTimeCode } from "@/lib/time"; +import { useProjectStore } from "@/stores/project-store"; +import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; +import { useState, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "./ui/breadcrumb"; + +export function EditorHeader() { + const { getTotalDuration } = useTimelineStore(); + const { activeProject, renameProject } = useProjectStore(); + const [isEditing, setIsEditing] = useState(false); + const [newName, setNewName] = useState(activeProject?.name || ""); + const inputRef = useRef<HTMLInputElement>(null); + + const handleExport = () => { + // TODO: Implement export functionality + // NOTE: This is already being worked on + console.log("Export project"); + }; + + const handleNameEdit = () => { + if (!activeProject) return; + setNewName(activeProject.name); + setIsEditing(true); + }; + + const handleNameSave = async () => { + if (activeProject && newName.trim() && newName !== activeProject.name) { + try { + await renameProject(activeProject.id, newName.trim()); + } catch (error) { + console.error("Failed to rename project:", error); + setNewName(activeProject.name); + } + } + setIsEditing(false); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") handleNameSave(); + else if (e.key === "Escape") setIsEditing(false); + }; + + const leftContent = ( + <div className="flex items-center gap-2"> + <Breadcrumb> + <BreadcrumbList className="text-current"> + <BreadcrumbItem> + <BreadcrumbLink asChild> + <Link + href="/projects" + className="hover:text-muted-foreground inline-flex items-center" + > + All Projects + </Link> + </BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + {isEditing ? ( + <Input + ref={inputRef} + className="text-sm font-medium w-40 xl:w-60 px-2 py-1 h-9 truncate" + value={newName} + onChange={(e) => setNewName(e.target.value)} + onBlur={handleNameSave} + onKeyDown={handleInputKeyDown} + onFocus={(e) => e.target.select()} + maxLength={64} + aria-label="Project name" + autoFocus + /> + ) : ( + <Button + className="text-sm font-medium cursor-pointer hover:text-muted-foreground flex items-center gap-2 group" + title="Click to rename" + variant="text" + tabIndex={0} + onClick={handleNameEdit} + > + <div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60"> + {activeProject?.name} + </div> + <SquarePen className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" /> + </Button> + )} + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + </div> + ); + + const centerContent = ( + <div className="flex items-center justify-center gap-2 text-xs"> + <span> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + </div> + ); + + const rightContent = ( + <nav className="flex items-center justify-end gap-2"> + <KeyboardShortcutsHelp /> + <Button + size="sm" + variant="primary" + className="h-7 text-xs" + onClick={handleExport} + > + <Download className="h-4 w-4" /> + <span className="text-sm">Export</span> + </Button> + </nav> + ); + + return ( + <HeaderBase + leftContent={leftContent} + centerContent={centerContent} + rightContent={rightContent} + className="bg-background h-[3.2rem] px-4 items-center grid grid-cols-3" + /> + ); +} diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index 25d040db..b8e9ae1d 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -1,53 +1,53 @@ -"use client";
-
-import { useEffect } from "react";
-import { Loader2 } from "lucide-react";
-import { useEditorStore } from "@/stores/editor-store";
-import {
- useKeybindingsListener,
- useKeybindingDisabler,
-} from "@/hooks/use-keybindings";
-import { useEditorActions } from "@/hooks/use-editor-actions";
-
-interface EditorProviderProps {
- children: React.ReactNode;
-}
-
-export function EditorProvider({ children }: EditorProviderProps) {
- const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
- const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
-
- // Set up action handlers
- useEditorActions();
-
- // Set up keybinding listener
- useKeybindingsListener();
-
- // Disable keybindings when initializing
- useEffect(() => {
- if (isInitializing || !isPanelsReady) {
- disableKeybindings();
- } else {
- enableKeybindings();
- }
- }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
-
- useEffect(() => {
- initializeApp();
- }, [initializeApp]);
-
- // Show loading screen while initializing
- if (isInitializing || !isPanelsReady) {
- return (
- <div className="h-screen w-screen flex items-center justify-center bg-background">
- <div className="flex flex-col items-center gap-4">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- <p className="text-sm text-muted-foreground">Loading editor...</p>
- </div>
- </div>
- );
- }
-
- // App is ready, render children
- return <>{children}</>;
-}
+"use client"; + +import { useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import { + useKeybindingsListener, + useKeybindingDisabler, +} from "@/hooks/use-keybindings"; +import { useEditorActions } from "@/hooks/use-editor-actions"; + +interface EditorProviderProps { + children: React.ReactNode; +} + +export function EditorProvider({ children }: EditorProviderProps) { + const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); + + // Set up action handlers + useEditorActions(); + + // Set up keybinding listener + useKeybindingsListener(); + + // Disable keybindings when initializing + useEffect(() => { + if (isInitializing || !isPanelsReady) { + disableKeybindings(); + } else { + enableKeybindings(); + } + }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); + + useEffect(() => { + initializeApp(); + }, [initializeApp]); + + // Show loading screen while initializing + if (isInitializing || !isPanelsReady) { + return ( + <div className="h-screen w-screen flex items-center justify-center bg-background"> + <div className="flex flex-col items-center gap-4"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <p className="text-sm text-muted-foreground">Loading editor...</p> + </div> + </div> + ); + } + + // App is ready, render children + return <>{children}</>; +} diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 75a5ae06..a673f995 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import WaveSurfer from 'wavesurfer.js'; +import React, { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; interface AudioWaveformProps { audioUrl: string; @@ -7,10 +7,10 @@ interface AudioWaveformProps { className?: string; } -const AudioWaveform: React.FC<AudioWaveformProps> = ({ - audioUrl, - height = 32, - className = '' +const AudioWaveform: React.FC<AudioWaveformProps> = ({ + audioUrl, + height = 32, + className = "", }) => { const waveformRef = useRef<HTMLDivElement>(null); const wavesurfer = useRef<WaveSurfer | null>(null); @@ -36,26 +36,26 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ wavesurfer.current = WaveSurfer.create({ container: waveformRef.current, - waveColor: 'rgba(255, 255, 255, 0.6)', - progressColor: 'rgba(255, 255, 255, 0.9)', - cursorColor: 'transparent', + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", barWidth: 2, barGap: 1, - height: height, + height, normalize: true, interact: false, }); // Event listeners - wavesurfer.current.on('ready', () => { + wavesurfer.current.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on('error', (err) => { - console.error('WaveSurfer error:', err); + wavesurfer.current.on("error", (err) => { + console.error("WaveSurfer error:", err); if (mounted) { setError(true); setIsLoading(false); @@ -63,9 +63,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ }); await wavesurfer.current.load(audioUrl); - } catch (err) { - console.error('Failed to initialize WaveSurfer:', err); + console.error("Failed to initialize WaveSurfer:", err); if (mounted) { setError(true); setIsLoading(false); @@ -90,7 +89,10 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ if (error) { return ( - <div className={`flex items-center justify-center ${className}`} style={{ height }}> + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > <span className="text-xs text-foreground/60">Audio unavailable</span> </div> ); @@ -103,13 +105,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ <span className="text-xs text-foreground/60">Loading...</span> </div> )} - <div - ref={waveformRef} - className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`} + <div + ref={waveformRef} + className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`} style={{ height }} /> </div> ); }; -export default AudioWaveform;
\ No newline at end of file +export default AudioWaveform; diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx index 3f3820db..e1fd257e 100644 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -1,57 +1,57 @@ -import { Upload, Plus, Image } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-interface MediaDragOverlayProps {
- isVisible: boolean;
- isProcessing?: boolean;
- progress?: number;
- onClick?: () => void;
- isEmptyState?: boolean;
-}
-
-export function MediaDragOverlay({
- isVisible,
- isProcessing = false,
- progress = 0,
- onClick,
- isEmptyState = false,
-}: MediaDragOverlayProps) {
- if (!isVisible) return null;
-
- const handleClick = (e: React.MouseEvent) => {
- if (isProcessing || !onClick) return;
- e.preventDefault();
- e.stopPropagation();
- onClick();
- };
-
- return (
- <div
- className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
- onClick={handleClick}
- >
- <div className="flex items-center justify-center">
- <Upload className="h-10 w-10 text-foreground" />
- </div>
-
- <div className="space-y-2">
- <p className="text-xs text-muted-foreground max-w-sm">
- {isProcessing
- ? `Processing your files (${progress}%)`
- : "Drag and drop videos, photos, and audio files here"}
- </p>
- </div>
-
- {isProcessing && (
- <div className="w-full max-w-xs">
- <div className="w-full bg-muted/50 rounded-full h-2">
- <div
- className="bg-primary h-2 rounded-full transition-all duration-300"
- style={{ width: `${progress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- );
-}
+import { Upload, Plus, Image } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; + isEmptyState?: boolean; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, + isEmptyState = false, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = (e: React.MouseEvent) => { + if (isProcessing || !onClick) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + + return ( + <div + className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" + onClick={handleClick} + > + <div className="flex items-center justify-center"> + <Upload className="h-10 w-10 text-foreground" /> + </div> + + <div className="space-y-2"> + <p className="text-xs text-muted-foreground max-w-sm"> + {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} + </p> + </div> + + {isProcessing && ( + <div className="w-full max-w-xs"> + <div className="w-full bg-muted/50 rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 90ac84f0..2c743618 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,54 +1,54 @@ -"use client";
-
-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: <AudioView />,
- text: <TextView />,
- stickers: (
- <div className="p-4 text-muted-foreground">
- Stickers view coming soon...
- </div>
- ),
- effects: (
- <div className="p-4 text-muted-foreground">
- Effects view coming soon...
- </div>
- ),
- transitions: (
- <div className="p-4 text-muted-foreground">
- Transitions view coming soon...
- </div>
- ),
- captions: (
- <div className="p-4 text-muted-foreground">
- Captions view coming soon...
- </div>
- ),
- filters: (
- <div className="p-4 text-muted-foreground">
- Filters view coming soon...
- </div>
- ),
- adjustment: (
- <div className="p-4 text-muted-foreground">
- Adjustment view coming soon...
- </div>
- ),
- };
-
- return (
- <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
- <TabBar />
- <div className="flex-1">{viewMap[activeTab]}</div>
- </div>
- );
-}
+"use client"; + +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: <AudioView />, + text: <TextView />, + stickers: ( + <div className="p-4 text-muted-foreground"> + Stickers view coming soon... + </div> + ), + effects: ( + <div className="p-4 text-muted-foreground"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="p-4 text-muted-foreground"> + Transitions view coming soon... + </div> + ), + captions: ( + <div className="p-4 text-muted-foreground"> + Captions view coming soon... + </div> + ), + filters: ( + <div className="p-4 text-muted-foreground"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="p-4 text-muted-foreground"> + Adjustment view coming soon... + </div> + ), + }; + + return ( + <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> + <TabBar /> + <div className="flex-1">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 9668f285..c661f51e 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -1,73 +1,73 @@ -import {
- CaptionsIcon,
- ArrowLeftRightIcon,
- SparklesIcon,
- StickerIcon,
- MusicIcon,
- VideoIcon,
- BlendIcon,
- SlidersHorizontalIcon,
- LucideIcon,
- TypeIcon,
-} from "lucide-react";
-import { create } from "zustand";
-
-export type Tab =
- | "media"
- | "audio"
- | "text"
- | "stickers"
- | "effects"
- | "transitions"
- | "captions"
- | "filters"
- | "adjustment";
-
-export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
- media: {
- icon: VideoIcon,
- label: "Media",
- },
- audio: {
- icon: MusicIcon,
- label: "Audio",
- },
- text: {
- icon: TypeIcon,
- label: "Text",
- },
- stickers: {
- icon: StickerIcon,
- label: "Stickers",
- },
- effects: {
- icon: SparklesIcon,
- label: "Effects",
- },
- transitions: {
- icon: ArrowLeftRightIcon,
- label: "Transitions",
- },
- captions: {
- icon: CaptionsIcon,
- label: "Captions",
- },
- filters: {
- icon: BlendIcon,
- label: "Filters",
- },
- adjustment: {
- icon: SlidersHorizontalIcon,
- label: "Adjustment",
- },
-};
-
-interface MediaPanelStore {
- activeTab: Tab;
- setActiveTab: (tab: Tab) => void;
-}
-
-export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
- activeTab: "media",
- setActiveTab: (tab) => set({ activeTab: tab }),
-}));
+import { + CaptionsIcon, + ArrowLeftRightIcon, + SparklesIcon, + StickerIcon, + MusicIcon, + VideoIcon, + BlendIcon, + SlidersHorizontalIcon, + LucideIcon, + TypeIcon, +} from "lucide-react"; +import { create } from "zustand"; + +export type Tab = + | "media" + | "audio" + | "text" + | "stickers" + | "effects" + | "transitions" + | "captions" + | "filters" + | "adjustment"; + +export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { + media: { + icon: VideoIcon, + label: "Media", + }, + audio: { + icon: MusicIcon, + label: "Audio", + }, + text: { + icon: TypeIcon, + label: "Text", + }, + stickers: { + icon: StickerIcon, + label: "Stickers", + }, + effects: { + icon: SparklesIcon, + label: "Effects", + }, + transitions: { + icon: ArrowLeftRightIcon, + label: "Transitions", + }, + captions: { + icon: CaptionsIcon, + label: "Captions", + }, + filters: { + icon: BlendIcon, + label: "Filters", + }, + adjustment: { + icon: SlidersHorizontalIcon, + label: "Adjustment", + }, +}; + +interface MediaPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +} + +export const useMediaPanelStore = create<MediaPanelStore>((set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), +})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index b05bee7e..bce84a46 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -1,124 +1,124 @@ -"use client";
-
-import { cn } from "@/lib/utils";
-import { Tab, tabs, useMediaPanelStore } from "./store";
-import { Button } from "@/components/ui/button";
-import { ChevronRight, ChevronLeft } from "lucide-react";
-import { useRef, useState, useEffect } from "react";
-
-export function TabBar() {
- const { activeTab, setActiveTab } = useMediaPanelStore();
- const scrollContainerRef = useRef<HTMLDivElement>(null);
- const [isAtEnd, setIsAtEnd] = useState(false);
- const [isAtStart, setIsAtStart] = useState(true);
-
- const scrollToEnd = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: scrollContainerRef.current.scrollWidth,
- });
- setIsAtEnd(true);
- setIsAtStart(false);
- }
- };
-
- const scrollToStart = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: 0,
- });
- setIsAtStart(true);
- setIsAtEnd(false);
- }
- };
-
- const checkScrollPosition = () => {
- if (scrollContainerRef.current) {
- const { scrollLeft, scrollWidth, clientWidth } =
- scrollContainerRef.current;
- const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
- const isAtStartNow = scrollLeft <= 1;
- setIsAtEnd(isAtEndNow);
- setIsAtStart(isAtStartNow);
- }
- };
-
- // We're using useEffect because we need to sync with external DOM scroll events
- useEffect(() => {
- const container = scrollContainerRef.current;
- if (!container) return;
-
- checkScrollPosition();
- container.addEventListener("scroll", checkScrollPosition);
-
- const resizeObserver = new ResizeObserver(checkScrollPosition);
- resizeObserver.observe(container);
-
- return () => {
- container.removeEventListener("scroll", checkScrollPosition);
- resizeObserver.disconnect();
- };
- }, []);
-
- return (
- <div className="flex">
- <ScrollButton
- direction="left"
- onClick={scrollToStart}
- isVisible={!isAtStart}
- />
- <div
- ref={scrollContainerRef}
- className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
- >
- {(Object.keys(tabs) as Tab[]).map((tabKey) => {
- const tab = tabs[tabKey];
- return (
- <div
- className={cn(
- "flex flex-col gap-0.5 items-center cursor-pointer",
- activeTab === tabKey ? "text-primary" : "text-muted-foreground"
- )}
- onClick={() => setActiveTab(tabKey)}
- key={tabKey}
- >
- <tab.icon className="!size-[1.1rem]" />
- <span className="text-[0.65rem]">{tab.label}</span>
- </div>
- );
- })}
- </div>
- <ScrollButton
- direction="right"
- onClick={scrollToEnd}
- isVisible={!isAtEnd}
- />
- </div>
- );
-}
-
-function ScrollButton({
- direction,
- onClick,
- isVisible,
-}: {
- direction: "left" | "right";
- onClick: () => void;
- isVisible: boolean;
-}) {
- if (!isVisible) return null;
-
- const Icon = direction === "left" ? ChevronLeft : ChevronRight;
-
- return (
- <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
- <Button
- size="icon"
- className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
- onClick={onClick}
- >
- <Icon className="!size-4 text-foreground" />
- </Button>
- </div>
- );
-}
+"use client"; + +import { cn } from "@/lib/utils"; +import { Tab, tabs, useMediaPanelStore } from "./store"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, ChevronLeft } from "lucide-react"; +import { useRef, useState, useEffect } from "react"; + +export function TabBar() { + const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [isAtEnd, setIsAtEnd] = useState(false); + const [isAtStart, setIsAtStart] = useState(true); + + const scrollToEnd = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + }); + setIsAtEnd(true); + setIsAtStart(false); + } + }; + + const scrollToStart = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: 0, + }); + setIsAtStart(true); + setIsAtEnd(false); + } + }; + + const checkScrollPosition = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; + const isAtStartNow = scrollLeft <= 1; + setIsAtEnd(isAtEndNow); + setIsAtStart(isAtStartNow); + } + }; + + // We're using useEffect because we need to sync with external DOM scroll events + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollPosition(); + container.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); + + return ( + <div className="flex"> + <ScrollButton + direction="left" + onClick={scrollToStart} + isVisible={!isAtStart} + /> + <div + ref={scrollContainerRef} + className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" + > + {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <div + className={cn( + "flex flex-col gap-0.5 items-center cursor-pointer", + activeTab === tabKey ? "text-primary" : "text-muted-foreground" + )} + onClick={() => setActiveTab(tabKey)} + key={tabKey} + > + <tab.icon className="!size-[1.1rem]" /> + <span className="text-[0.65rem]">{tab.label}</span> + </div> + ); + })} + </div> + <ScrollButton + direction="right" + onClick={scrollToEnd} + isVisible={!isAtEnd} + /> + </div> + ); +} + +function ScrollButton({ + direction, + onClick, + isVisible, +}: { + direction: "left" | "right"; + onClick: () => void; + isVisible: boolean; +}) { + if (!isVisible) return null; + + const Icon = direction === "left" ? ChevronLeft : ChevronRight; + + return ( + <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> + <Button + size="icon" + className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" + onClick={onClick} + > + <Icon className="!size-4 text-foreground" /> + </Button> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx index 7ddf465c..ba64d749 100644 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -1,18 +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>
- );
-}
+"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> + ); +} 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 0189720f..66d0d00d 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,298 +1,298 @@ -"use client";
-
-import { useDragDrop } from "@/hooks/use-drag-drop";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { Image, Loader2, Music, Plus, Video } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-
-export function MediaView() {
- const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
- const { activeProject } = useProjectStore();
- const fileInputRef = useRef<HTMLInputElement>(null);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const [mediaFilter, setMediaFilter] = useState("all");
-
- const processFiles = async (files: FileList | File[]) => {
- if (!files || files.length === 0) return;
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setIsProcessing(true);
- setProgress(0);
- try {
- // Process files (extract metadata, generate thumbnails, etc.)
- const processedItems = await processMediaFiles(files, (p) =>
- setProgress(p)
- );
- // Add each processed media item to the store
- for (const item of processedItems) {
- await addMediaItem(activeProject.id, item);
- }
- } catch (error) {
- // Show error toast if processing fails
- console.error("Error processing files:", error);
- toast.error("Failed to process files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- };
-
- const { isDragOver, dragProps } = useDragDrop({
- // When files are dropped, process them
- onDrop: processFiles,
- });
-
- const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
-
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- // When files are selected via file picker, process them
- if (e.target.files) processFiles(e.target.files);
- e.target.value = ""; // Reset input
- };
-
- const handleRemove = async (e: React.MouseEvent, id: string) => {
- // Remove a media item from the store
- e.stopPropagation();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- // Media store now handles cascade deletion automatically
- await removeMediaItem(activeProject.id, id);
- };
-
- const formatDuration = (duration: number) => {
- // Format seconds as mm:ss
- const min = Math.floor(duration / 60);
- const sec = Math.floor(duration % 60);
- return `${min}:${sec.toString().padStart(2, "0")}`;
- };
-
- const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
-
- useEffect(() => {
- const filtered = mediaItems.filter((item) => {
- if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
- return false;
- }
-
- if (
- searchQuery &&
- !item.name.toLowerCase().includes(searchQuery.toLowerCase())
- ) {
- return false;
- }
-
- return true;
- });
-
- setFilteredMediaItems(filtered);
- }, [mediaItems, mediaFilter, searchQuery]);
-
- const renderPreview = (item: MediaItem) => {
- // Render a preview for each media type (image, video, audio, unknown)
- if (item.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={item.url}
- alt={item.name}
- className="max-w-full max-h-full object-contain"
- loading="lazy"
- />
- </div>
- );
- }
-
- if (item.type === "video") {
- if (item.thumbnailUrl) {
- return (
- <div className="relative w-full h-full">
- <img
- src={item.thumbnailUrl}
- alt={item.name}
- className="w-full h-full object-cover rounded"
- loading="lazy"
- />
- <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
- <Video className="h-6 w-6 text-white drop-shadow-md" />
- </div>
- {item.duration && (
- <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
- {formatDuration(item.duration)}
- </div>
- )}
- </div>
- );
- }
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Video className="h-6 w-6 mb-1" />
- <span className="text-xs">Video</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- if (item.type === "audio") {
- return (
- <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Image className="h-6 w-6" />
- <span className="text-xs mt-1">Unknown</span>
- </div>
- );
- };
-
- return (
- <>
- {/* Hidden file input for uploading media */}
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*,video/*,audio/*"
- multiple
- className="hidden"
- onChange={handleFileChange}
- />
-
- <div
- className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="p-3 pb-2">
- {/* Search and filter controls */}
- <div className="flex gap-2">
- <Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-9 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="all">All</SelectItem>
- <SelectItem value="video">Video</SelectItem>
- <SelectItem value="audio">Audio</SelectItem>
- <SelectItem value="image">Image</SelectItem>
- </SelectContent>
- </Select>
- <Input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 h-9 text-xs"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- <Button
- variant="outline"
- size="lg"
- onClick={handleFileSelect}
- disabled={isProcessing}
- 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" />
- ) : (
- <Plus className="h-4 w-4" />
- )}
- </Button>
- </div>
- </div>
-
- <div className="flex-1 overflow-y-auto p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
- />
- ) : (
- <div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
- >
- {/* Render each media item as a draggable button */}
- {filteredMediaItems.map((item) => (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <DraggableMediaItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: item.type,
- name: item.name,
- }}
- showPlusOnDrag={false}
- onAddToTimeline={(currentTime) =>
- useTimelineStore
- .getState()
- .addMediaAtTime(item, currentTime)
- }
- rounded={false}
- />
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(e) => handleRemove(e, item.id)}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </div>
- )}
- </div>
- </div>
- </>
- );
-}
+"use client"; + +import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { Image, Loader2, Music, Plus, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function MediaView() { + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const fileInputRef = useRef<HTMLInputElement>(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); + + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + // Process files (extract metadata, generate thumbnails, etc.) + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); + // Add each processed media item to the store + for (const item of processedItems) { + await addMediaItem(activeProject.id, item); + } + } catch (error) { + // Show error toast if processing fails + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps } = useDragDrop({ + // When files are dropped, process them + onDrop: processFiles, + }); + + const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; + + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + // Media store now handles cascade deletion automatically + await removeMediaItem(activeProject.id, id); + }; + + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; + + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); + + useEffect(() => { + const filtered = mediaItems.filter((item) => { + if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { + return false; + } + + if ( + searchQuery && + !item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } + + return true; + }); + + setFilteredMediaItems(filtered); + }, [mediaItems, mediaFilter, searchQuery]); + + const renderPreview = (item: MediaItem) => { + // Render a preview for each media type (image, video, audio, unknown) + if (item.type === "image") { + return ( + <div className="w-full h-full flex items-center justify-center"> + <img + src={item.url} + alt={item.name} + className="max-w-full max-h-full object-contain" + loading="lazy" + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative w-full h-full"> + <img + src={item.thumbnailUrl} + alt={item.name} + className="w-full h-full object-cover rounded" + loading="lazy" + /> + <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> + <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + {item.duration && ( + <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + {formatDuration(item.duration)} + </div> + )} + </div> + ); + } + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Video className="h-6 w-6 mb-1" /> + <span className="text-xs">Video</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + if (item.type === "audio") { + return ( + <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <Music className="h-6 w-6 mb-1" /> + <span className="text-xs">Audio</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Image className="h-6 w-6" /> + <span className="text-xs mt-1">Unknown</span> + </div> + ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + <input + ref={fileInputRef} + type="file" + accept="image/*,video/*,audio/*" + multiple + className="hidden" + onChange={handleFileChange} + /> + + <div + className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="p-3 pb-2"> + {/* Search and filter controls */} + <div className="flex gap-2"> + <Select value={mediaFilter} onValueChange={setMediaFilter}> + <SelectTrigger className="w-[80px] h-9 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All</SelectItem> + <SelectItem value="video">Video</SelectItem> + <SelectItem value="audio">Audio</SelectItem> + <SelectItem value="image">Image</SelectItem> + </SelectContent> + </Select> + <Input + type="text" + placeholder="Search media..." + className="min-w-[60px] flex-1 h-9 text-xs" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + <Button + variant="outline" + size="lg" + onClick={handleFileSelect} + disabled={isProcessing} + 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" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={handleFileSelect} + isEmptyState={filteredMediaItems.length === 0 && !isDragOver} + /> + ) : ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </div> + )} + </div> + </div> + </> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 6d0d310b..0686190e 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,53 +1,53 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { type TextElement } from "@/types/timeline";
-
-let textData: TextElement = {
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center" as const,
- fontWeight: "normal" as const,
- fontStyle: "normal" as const,
- textDecoration: "none" as const,
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
-};
-
-export function TextView() {
- return (
- <div className="p-4">
- <DraggableMediaItem
- name="Default text"
- preview={
- <div className="flex items-center justify-center w-full h-full bg-accent rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: textData.id,
- type: textData.type,
- name: textData.name,
- content: textData.content,
- }}
- aspectRatio={1}
- onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addTextAtTime(textData, currentTime)
- }
- showLabel={false}
- />
- </div>
- );
-}
+import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { type TextElement } from "@/types/timeline"; + +const textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; + +export function TextView() { + return ( + <div className="p-4"> + <DraggableMediaItem + name="Default text" + preview={ + <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, + }} + aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } + showLabel={false} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2cba1374..f09e8462 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -368,7 +368,7 @@ export function PreviewPanel() { 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> + <div className="flex-1" /> {hasAnyElements ? ( <div ref={previewRef} @@ -402,7 +402,7 @@ export function PreviewPanel() { </div> ) : null} - <div className="flex-1"></div> + <div className="flex-1" /> <PreviewToolbar hasAnyElements={hasAnyElements} diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx index 384f0c5e..8d69e36a 100644 --- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline";
-
-export function AudioProperties({ element }: { element: MediaElement }) {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
\ No newline at end of file +import { MediaElement } from "@/types/timeline"; + +export function AudioProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index c2c514cf..4ea28bb2 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline";
-
-export function MediaProperties({ element }: { element: MediaElement }) {
- return <div className="space-y-4 p-5">Media properties</div>;
-}
+import { MediaElement } from "@/types/timeline"; + +export function MediaProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Media properties</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index beb8c799..804ffb39 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -1,47 +1,47 @@ -import { cn } from "@/lib/utils";
-
-interface PropertyItemProps {
- direction?: "row" | "column";
- children: React.ReactNode;
- className?: string;
-}
-
-export function PropertyItem({
- direction = "row",
- children,
- className,
-}: PropertyItemProps) {
- return (
- <div
- className={cn(
- "flex gap-2",
- direction === "row"
- ? "items-center justify-between gap-6"
- : "flex-col gap-1",
- className
- )}
- >
- {children}
- </div>
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <label className={cn("text-xs", className)}>{children}</label>;
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <div className={cn("flex-1", className)}>{children}</div>;
-}
+import { cn } from "@/lib/utils"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( + <div + className={cn( + "flex gap-2", + direction === "row" + ? "items-center justify-between gap-6" + : "flex-col gap-1", + className + )} + > + {children} + </div> + ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <label className={cn("text-xs", className)}>{children}</label>; +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cn("flex-1", className)}>{children}</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index e13dabc8..c55c3dae 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -1,76 +1,76 @@ -import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import { FontFamily } from "@/constants/font-constants";
-import { TextElement } from "@/types/timeline";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { Slider } from "@/components/ui/slider";
-import { Input } from "@/components/ui/input";
-import {
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
-
-export function TextProperties({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const { updateTextElement } = useTimelineStore();
-
- return (
- <div className="space-y-6 p-5">
- <Textarea
- placeholder="Name"
- defaultValue={element.content}
- className="min-h-[4.5rem] resize-none bg-background/50"
- onChange={(e) =>
- updateTextElement(trackId, element.id, { content: e.target.value })
- }
- />
- <PropertyItem direction="row">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- updateTextElement(trackId, element.id, { fontFamily: value })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- defaultValue={[element.fontSize]}
- min={8}
- max={300}
- step={1}
- onValueChange={([value]) =>
- updateTextElement(trackId, element.id, { fontSize: value })
- }
- className="w-full"
- />
- <Input
- type="number"
- value={element.fontSize}
- onChange={(e) =>
- updateTextElement(trackId, element.id, {
- fontSize: parseInt(e.target.value),
- })
- }
- className="w-12 !text-xs h-7 rounded-sm text-center
- [appearance:textfield]
- [&::-webkit-outer-spin-button]:appearance-none
- [&::-webkit-inner-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
+import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import { FontFamily } from "@/constants/font-constants"; +import { TextElement } from "@/types/timeline"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const { updateTextElement } = useTimelineStore(); + + return ( + <div className="space-y-6 p-5"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="min-h-[4.5rem] resize-none bg-background/50" + onChange={(e) => + updateTextElement(trackId, element.id, { content: e.target.value }) + } + /> + <PropertyItem direction="row"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + updateTextElement(trackId, element.id, { fontFamily: value }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + defaultValue={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => + updateTextElement(trackId, element.id, { fontSize: value }) + } + className="w-full" + /> + <Input + type="number" + value={element.fontSize} + onChange={(e) => + updateTextElement(trackId, element.id, { + fontSize: parseInt(e.target.value), + }) + } + className="w-12 !text-xs h-7 rounded-sm text-center + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 5c452f7c..0a97c6f8 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -1,58 +1,58 @@ -"use client";
-
-import { useEffect, useRef } from "react";
-
-interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement>;
- isActive: boolean;
-}
-
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate relative positions within the container
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- // Calculate the selection rectangle bounds
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- // Update the selection box position and size
- if (selectionBoxRef.current) {
- selectionBoxRef.current.style.left = `${left}px`;
- selectionBoxRef.current.style.top = `${top}px`;
- selectionBoxRef.current.style.width = `${width}px`;
- selectionBoxRef.current.style.height = `${height}px`;
- }
- }, [startPos, currentPos, isActive, containerRef]);
-
- if (!isActive || !startPos || !currentPos) return null;
-
- return (
- <div
- ref={selectionBoxRef}
- className="absolute pointer-events-none z-50"
- style={{
- backgroundColor: "hsl(var(--foreground) / 0.1)",
- }}
- />
- );
-}
+"use client"; + +import { useEffect, useRef } from "react"; + +interface SelectionBoxProps { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject<HTMLElement>; + isActive: boolean; +} + +export function SelectionBox({ + startPos, + currentPos, + containerRef, + isActive, +}: SelectionBoxProps) { + const selectionBoxRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!isActive || !startPos || !currentPos || !containerRef.current) return; + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + // Calculate relative positions within the container + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + // Calculate the selection rectangle bounds + const left = Math.min(startX, currentX); + const top = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + // Update the selection box position and size + if (selectionBoxRef.current) { + selectionBoxRef.current.style.left = `${left}px`; + selectionBoxRef.current.style.top = `${top}px`; + selectionBoxRef.current.style.width = `${width}px`; + selectionBoxRef.current.style.height = `${height}px`; + } + }, [startPos, currentPos, isActive, containerRef]); + + if (!isActive || !startPos || !currentPos) return null; + + return ( + <div + ref={selectionBoxRef} + className="absolute pointer-events-none z-50" + style={{ + backgroundColor: "hsl(var(--foreground) / 0.1)", + }} + /> + ); +} diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 83a1f678..302a52bb 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -48,7 +48,7 @@ export function SnapIndicator({ width: "2px", }} > - <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> + <div className={"w-0.5 h-full bg-primary/40 opacity-80"} /> </div> ); } diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx index 39281a9a..f2b0568d 100644 --- a/apps/web/src/components/editor/speed-control.tsx +++ b/apps/web/src/components/editor/speed-control.tsx @@ -43,4 +43,4 @@ export function SpeedControl() { </div> </div> ); -}
\ No newline at end of file +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 2360eb8a..fd2b2a11 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -278,7 +278,7 @@ export function Timeline() { Math.min( duration, (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ) ); @@ -501,7 +501,9 @@ export function Timeline() { return ( <div - className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} + className={ + "h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden" + } {...dragProps} onMouseEnter={() => setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} @@ -598,19 +600,21 @@ 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) => { @@ -620,13 +624,14 @@ export function Timeline() { if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { + } + if (minutes > 0) { return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { + } + if (interval >= 1) { return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; } + return `${secs.toFixed(1)}s`; }; return formatTime(time); })()} @@ -709,7 +714,7 @@ export function Timeline() { }} > {tracks.length === 0 ? ( - <div></div> + <div /> ) : ( <> {tracks.map((track, index) => ( diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 7a357067..509ce9d7 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,116 +1,116 @@ -"use client";
-
-import { useRef } from "react";
-import { TimelineTrack } from "@/types/timeline";
-import {
- TIMELINE_CONSTANTS,
- getTotalTracksHeight,
-} from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
-
-interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- trackLabelsRef?: React.RefObject<HTMLDivElement>;
- timelineRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
- isSnappingToPlayhead?: boolean;
-}
-
-export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
-}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- // Use timeline container height minus a few pixels for breathing room
- 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 +
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
-
- return (
- <div
- ref={playheadRef}
- className="absolute pointer-events-auto z-[150]"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: "2px", // Slightly wider for better click target
- }}
- onMouseDown={handlePlayheadMouseDown}
- >
- {/* The playhead line spanning full height */}
- <div
- className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
- />
-
- {/* Playhead dot indicator at the top (in ruler area) */}
- <div
- className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
- />
- </div>
- );
-}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- return { handleRulerMouseDown, isDraggingRuler };
-}
-
-export { TimelinePlayhead as default };
+"use client"; + +import { useRef } from "react"; +import { TimelineTrack } from "@/types/timeline"; +import { + TIMELINE_CONSTANTS, + getTotalTracksHeight, +} from "@/constants/timeline-constants"; +import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; + +interface TimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + seek: (time: number) => void; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + timelineRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + currentTime, + duration, + zoomLevel, + tracks, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + trackLabelsRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Use timeline container height minus a few pixels for breathing room + 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 + + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + return ( + <div + ref={playheadRef} + className="absolute pointer-events-auto z-[150]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", // Slightly wider for better click target + }} + onMouseDown={handlePlayheadMouseDown} + > + {/* The playhead line spanning full height */} + <div + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} + /> + + {/* Playhead dot indicator at the top (in ruler area) */} + <div + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + /> + </div> + ); +} + +// Also export a hook for getting ruler handlers +export function useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { + const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + return { handleRulerMouseDown, isDraggingRuler }; +} + +export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 82ca0b98..097fc051 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1,1151 +1,1151 @@ -"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- sortTracksByOrder,
- ensureMainTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- 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,
- updateElementStartTimeWithRipple,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- rippleEditingEnabled,
- } = useTimelineStore();
-
- const { currentTime } = usePlaybackStore();
-
- // Initialize snapping hook
- const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
- snapThreshold: 10,
- enableElementSnapping: snappingEnabled,
- enablePlayheadSnapping: snappingEnabled,
- });
-
- // Helper function for drop snapping that tries both edges
- const getDropSnappedTime = (
- dropTime: number,
- elementDuration: number,
- excludeElementId?: string
- ) => {
- if (!snappingEnabled) {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- return snapTimeToFrame(dropTime, projectFps);
- }
-
- // Try snapping both start and end edges for drops
- const startSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- return bestSnapResult.snappedTime;
- };
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Apply snapping if enabled
- let finalTime = adjustedTime;
- let snapPoint = null;
- if (snappingEnabled) {
- // Find the element being dragged to get its duration
- let elementDuration = 5; // fallback duration
- if (dragState.elementId && dragState.trackId) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const element = sourceTrack?.elements.find(
- (e) => e.id === dragState.elementId
- );
- if (element) {
- elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- }
- }
-
- // Try snapping both start and end edges
- const startSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.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) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- }
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- return;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- finalTime
- );
- }
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId!,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- }
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- }
- }
- }
- }
-
- if (isTrackThatStartedDrag) {
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // If element is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline elements and media items
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- // Text elements have default duration of 5 seconds
- const newElementDuration = 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return snappedTime < existingEnd && newElementEnd > existingStart;
- });
- } else {
- // Media elements
- const mediaItem = mediaItems.find(
- (item) => item.id === dragData.id
- );
- if (mediaItem) {
- const newElementDuration = mediaItem.duration || 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && newElementEnd > existingStart
- );
- });
- }
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineElement) {
- try {
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (timelineElementData) {
- const { elementId, trackId: fromTrackId } =
- JSON.parse(timelineElementData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: any) => c.id === elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const snappedTime = getDropSnappedTime(
- dropTime,
- movingElementDuration,
- elementId
- );
- const movingElementEnd = snappedTime + movingElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && movingElementEnd > existingStart
- );
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
- setWouldOverlap(false);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Debug logging
- console.log(
- JSON.stringify({
- message: "Drop event started in timeline track",
- dataTransferTypes: Array.from(e.dataTransfer.types),
- trackId: track.id,
- trackType: track.type,
- })
- );
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const mouseY = e.clientY - rect.top; // Get Y position relative to this track
- const newStartTime =
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const snappedTime = snapTimeToFrame(newStartTime, projectFps);
-
- // Calculate drop position relative to tracks
- const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
-
- // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
- let dropPosition: "above" | "on" | "below";
- if (mouseY < 20) {
- dropPosition = "above";
- } else if (mouseY > 40) {
- dropPosition = "below";
- } else {
- dropPosition = "on";
- }
-
- try {
- if (hasTimelineElement) {
- // Handle timeline element movement
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (!timelineElementData) return;
-
- const {
- elementId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineElementData);
-
- // Find the element being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: TimelineElementType) => c.id === elementId
- );
-
- if (!movingElement) {
- toast.error("Element not found");
- return;
- }
-
- // Check for overlaps with existing elements (excluding the moving element itself)
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
-
- // Adjust position based on where user clicked on the element
- const adjustedStartTime = newStartTime - clickOffsetTime;
- const snappedStartTime = getDropSnappedTime(
- adjustedStartTime,
- movingElementDuration,
- elementId
- );
- const finalStartTime = Math.max(0, snappedStartTime);
- const movingElementEnd = finalStartTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- // Skip the element being moved if it's on the same track
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- finalStartTime < existingEnd && movingElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move element here - it would overlap with existing elements"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- let targetTrackId = track.id;
- let targetTrack = track;
-
- // Handle position-aware track creation for text
- if (track.type !== "text" || dropPosition !== "on") {
- // Text tracks should go above the main track
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is not text type
- // Insert above main track if main track exists, otherwise at top
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- } else {
- insertIndex = 0; // Top of timeline
- }
- }
-
- targetTrackId = insertTrackAt("text", insertIndex);
- // Get the updated tracks array after creating the new track
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = 5; // Default text duration
- const textSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = textSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- textSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: textSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- });
- } else {
- // Handle media items
- const mediaItem = mediaItems.find((item) => item.id === dragData.id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- let targetTrackId = track.id;
-
- // Check if track type is compatible
- const isVideoOrImage =
- dragData.type === "video" || dragData.type === "image";
- const isAudio = dragData.type === "audio";
- const isCompatible = isVideoOrImage
- ? canElementGoOnTrack("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", track.type)
- : false;
-
- let targetTrack = tracks.find((t) => t.id === targetTrackId);
-
- // Handle position-aware track creation for media elements
- if (!isCompatible || dropPosition !== "on") {
- if (isVideoOrImage) {
- // For video/image, check if we need a main track or additional media track
- const mainTrack = getMainTrack(tracks);
-
- if (!mainTrack) {
- // No main track exists, create it
- targetTrackId = addTrack("media");
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- } else if (
- mainTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- // Main track exists and is empty, use it
- targetTrackId = mainTrack.id;
- targetTrack = mainTrack;
- } else {
- // Create new media track
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
- }
-
- targetTrackId = insertTrackAt("audio", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- }
-
- if (!targetTrack) return;
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = mediaItem.duration || 5;
- const mediaSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = mediaSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- mediaSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: mediaSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-elements-container min-w-full"
- >
- {track.elements.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
- </div>
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicate = () => {
- const { addElementToTrack } = useTimelineStore.getState();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
- };
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { toast } from "sonner"; +import { TimelineElement } from "./timeline-element"; +import { + TimelineTrack, + sortTracksByOrder, + ensureMainTrack, + getMainTrack, + canElementGoOnTrack, +} from "@/types/timeline"; +import { usePlaybackStore } from "@/stores/playback-store"; +import type { + TimelineElement as TimelineElementType, + DragData, +} from "@/types/timeline"; +import { + snapTimeToFrame, + 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, + updateElementStartTimeWithRipple, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + snappingEnabled, + rippleEditingEnabled, + } = useTimelineStore(); + + const { currentTime } = usePlaybackStore(); + + // Initialize snapping hook + const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ + snapThreshold: 10, + enableElementSnapping: snappingEnabled, + enablePlayheadSnapping: snappingEnabled, + }); + + // Helper function for drop snapping that tries both edges + const getDropSnappedTime = ( + dropTime: number, + elementDuration: number, + excludeElementId?: string + ) => { + if (!snappingEnabled) { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + return snapTimeToFrame(dropTime, projectFps); + } + + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true // snap to start edge + ); + + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + return bestSnapResult.snappedTime; + }; + + const timelineRef = useRef<HTMLDivElement>(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState<number | null>(null); + const [wouldOverlap, setWouldOverlap] = useState(false); + const dragCounterRef = useRef(0); + const [mouseDownLocation, setMouseDownLocation] = useState<{ + x: number; + y: number; + } | null>(null); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + // On first mouse move during drag, ensure the element is selected + if (dragState.elementId && dragState.trackId) { + const isSelected = selectedElements.some( + (c) => + c.trackId === dragState.trackId && + c.elementId === dragState.elementId + ); + + if (!isSelected) { + // Select this element (replacing other selections) since we're dragging it + selectElement(dragState.trackId, dragState.elementId, false); + } + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + ); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + + // Apply snapping if enabled + let finalTime = adjustedTime; + let snapPoint = null; + if (snappingEnabled) { + // Find the element being dragged to get its duration + let elementDuration = 5; // fallback duration + if (dragState.elementId && dragState.trackId) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const element = sourceTrack?.elements.find( + (e) => e.id === dragState.elementId + ); + if (element) { + elementDuration = + element.duration - element.trimStart - element.trimEnd; + } + } + + // Try snapping both start and end edges + const startSnapResult = snapElementEdge( + adjustedTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined, + true // snap to start edge + ); + + const endSnapResult = snapElementEdge( + adjustedTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined, + false // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.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) => { + if (!dragState.elementId || !dragState.trackId) return; + + // If this track initiated the drag, we should handle the mouse up regardless of where it occurs + const isTrackThatStartedDrag = dragState.trackId === track.id; + + const timelineRect = timelineRef.current?.getBoundingClientRect(); + if (!timelineRect) { + if (isTrackThatStartedDrag) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + dragState.currentTime + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId, + dragState.currentTime + ); + } + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + return; + } + + const isMouseOverThisTrack = + e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; + + if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; + + const finalTime = dragState.currentTime; + + if (isMouseOverThisTrack) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId, + finalTime + ); + } + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId + ); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId!, + finalTime + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId!, + finalTime + ); + } + }); + } + } + } + } else if (isTrackThatStartedDrag) { + // Mouse is not over this track, but this track started the drag + // This means user released over ruler/outside - update position within same track + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + if (existingElement.id === dragState.elementId) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime + ); + } else { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } + } + } + } + + if (isTrackThatStartedDrag) { + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateElementStartTime, + moveElementToTrack, + endDragAction, + selectedElements, + selectElement, + onSnapPointChange, + ]); + + const handleElementMouseDown = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + + // Detect right-click (button 2) and handle selection without starting drag + const isRightClick = e.button === 2; + const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; + + if (isRightClick) { + // Handle right-click selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + // If element is not selected, select it (keep other selections if multi-select) + if (!isSelected) { + selectElement(track.id, element.id, isMultiSelect); + } + // If element is already selected, keep it selected + + // Don't start drag action for right-clicks + return; + } + + // Handle multi-selection for left-click with modifiers + if (isMultiSelect) { + selectElement(track.id, element.id, true); + } + + // Calculate the offset from the left edge of the element to where the user clicked + const elementElement = e.currentTarget as HTMLElement; + const elementRect = elementElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - elementRect.left; + const clickOffsetTime = + clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + + startDragAction( + element.id, + track.id, + e.clientX, + element.startTime, + clickOffsetTime + ); + }; + + const handleElementClick = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + e.stopPropagation(); + + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } + } + + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + + // Handle single selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + if (!isSelected) { + // If element is not selected, select it (replacing other selections) + selectElement(track.id, element.id, false); + } + // If element is already selected, keep it selected (do nothing) + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline elements and media items + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container" + ) as HTMLElement; + let dropTime = 0; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + } + + // Check for potential overlaps and show appropriate feedback + let wouldOverlap = false; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + // Text elements have default duration of 5 seconds + const newElementDuration = 5; + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration + ); + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + } else { + // Media elements + const mediaItem = mediaItems.find( + (item) => item.id === dragData.id + ); + if (mediaItem) { + const newElementDuration = mediaItem.duration || 5; + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration + ); + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && newElementEnd > existingStart + ); + }); + } + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineElement) { + try { + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (timelineElementData) { + const { elementId, trackId: fromTrackId } = + JSON.parse(timelineElementData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: any) => c.id === elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const snappedTime = getDropSnappedTime( + dropTime, + movingElementDuration, + elementId + ); + const movingElementEnd = snappedTime + movingElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && movingElementEnd > existingStart + ); + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setWouldOverlap(true); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); + return; + } + + e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; + setWouldOverlap(false); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Debug logging + console.log( + JSON.stringify({ + message: "Drop event started in timeline track", + dataTransferTypes: Array.from(e.dataTransfer.types), + trackId: track.id, + trackType: track.type, + }) + ); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setWouldOverlap(false); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container" + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const mouseY = e.clientY - rect.top; // Get Y position relative to this track + const newStartTime = + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(newStartTime, projectFps); + + // Calculate drop position relative to tracks + const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); + + // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) + let dropPosition: "above" | "on" | "below"; + if (mouseY < 20) { + dropPosition = "above"; + } else if (mouseY > 40) { + dropPosition = "below"; + } else { + dropPosition = "on"; + } + + try { + if (hasTimelineElement) { + // Handle timeline element movement + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (!timelineElementData) return; + + const { + elementId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineElementData); + + // Find the element being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: TimelineElementType) => c.id === elementId + ); + + if (!movingElement) { + toast.error("Element not found"); + return; + } + + // Check for overlaps with existing elements (excluding the moving element itself) + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + + // Adjust position based on where user clicked on the element + const adjustedStartTime = newStartTime - clickOffsetTime; + const snappedStartTime = getDropSnappedTime( + adjustedStartTime, + movingElementDuration, + elementId + ); + const finalStartTime = Math.max(0, snappedStartTime); + const movingElementEnd = finalStartTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + // Skip the element being moved if it's on the same track + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + finalStartTime < existingEnd && movingElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot move element here - it would overlap with existing elements" + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + let targetTrackId = track.id; + let targetTrack = track; + + // Handle position-aware track creation for text + if (track.type !== "text" || dropPosition !== "on") { + // Text tracks should go above the main track + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + // Insert above main track if main track exists, otherwise at top + if (mainTrack) { + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); + insertIndex = mainTrackIndex; + } else { + insertIndex = 0; // Top of timeline + } + } + + targetTrackId = insertTrackAt("text", insertIndex); + // Get the updated tracks array after creating the new track + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + + // Check for overlaps with existing elements in target track + const newElementDuration = 5; // Default text duration + const textSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration + ); + const newElementEnd = textSnappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + textSnappedTime < existingEnd && newElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: textSnappedTime, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + let targetTrackId = track.id; + + // Check if track type is compatible + const isVideoOrImage = + dragData.type === "video" || dragData.type === "image"; + const isAudio = dragData.type === "audio"; + const isCompatible = isVideoOrImage + ? canElementGoOnTrack("media", track.type) + : isAudio + ? canElementGoOnTrack("media", track.type) + : false; + + let targetTrack = tracks.find((t) => t.id === targetTrackId); + + // Handle position-aware track creation for media elements + if (!isCompatible || dropPosition !== "on") { + if (isVideoOrImage) { + // For video/image, check if we need a main track or additional media track + const mainTrack = getMainTrack(tracks); + + if (!mainTrack) { + // No main track exists, create it + targetTrackId = addTrack("media"); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } else if ( + mainTrack.elements.length === 0 && + dropPosition === "on" + ) { + // Main track exists and is empty, use it + targetTrackId = mainTrack.id; + targetTrack = mainTrack; + } else { + // Create new media track + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert above main track + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); + insertIndex = mainTrackIndex; + } + + targetTrackId = insertTrackAt("media", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } else if (isAudio) { + // Audio tracks go at the bottom + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert after main track (bottom area) + if (mainTrack) { + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); + insertIndex = mainTrackIndex + 1; + } else { + insertIndex = tracks.length; // Bottom of timeline + } + } + + targetTrackId = insertTrackAt("audio", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } + + if (!targetTrack) return; + + // Check for overlaps with existing elements in target track + const newElementDuration = mediaItem.duration || 5; + const mediaSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration + ); + const newElementEnd = mediaSnappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + mediaSnappedTime < existingEnd && newElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: mediaSnappedTime, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( + <div + className="w-full h-full hover:bg-muted/20" + onClick={(e) => { + // If clicking empty area (not on an element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > + <div + ref={timelineRef} + className="h-full relative track-elements-container min-w-full" + > + {track.elements.length === 0 ? ( + <div + className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ + isDropping + ? wouldOverlap + ? "border-red-500 bg-red-500/10 text-red-600" + : "border-blue-500 bg-blue-500/10 text-blue-600" + : "border-muted/30" + }`} + > + {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop element here" + : ""} + </div> + ) : ( + <> + {track.elements.map((element) => { + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + const handleElementSplit = () => { + const { currentTime } = usePlaybackStore(); + const { splitElement } = useTimelineStore(); + const splitTime = currentTime; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + const secondElementId = splitElement( + track.id, + element.id, + splitTime + ); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicate = () => { + const { addElementToTrack } = useTimelineStore.getState(); + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDelete = () => { + const { removeElementFromTrack } = useTimelineStore.getState(); + removeElementFromTrack(track.id, element.id); + }; + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + /> + ); + })} + </> + )} + </div> + </div> + ); +} diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx index 0254f4a3..a4863d27 100644 --- a/apps/web/src/components/header-base.tsx +++ b/apps/web/src/components/header-base.tsx @@ -1,37 +1,39 @@ -"use client";
-
-import type { ReactNode } from "react";
-import { cn } from "@/lib/utils";
-
-interface HeaderBaseProps {
- leftContent?: ReactNode;
- centerContent?: ReactNode;
- rightContent?: ReactNode;
- className?: string;
- children?: ReactNode;
-}
-
-export function HeaderBase({
- leftContent,
- centerContent,
- rightContent,
- className,
- children,
-}: HeaderBaseProps) {
- // If children is provided, render it directly without the grid layout
- if (children) {
- return (
- <header className={cn("px-6 h-16 flex items-center", className)}>
- {children}
- </header>
- );
- }
-
- return (
- <header className={cn("px-6 h-14 flex justify-between", className)}>
- {leftContent && leftContent}
- {centerContent && centerContent}
- {rightContent && rightContent}
- </header>
- );
-}
+"use client"; + +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +interface HeaderBaseProps { + leftContent?: ReactNode; + centerContent?: ReactNode; + rightContent?: ReactNode; + className?: string; + children?: ReactNode; +} + +export function HeaderBase({ + leftContent, + centerContent, + rightContent, + className, + children, +}: HeaderBaseProps) { + // If children is provided, render it directly without the grid layout + if (children) { + return ( + <header className={cn("px-6 h-16 flex items-center", className)}> + {children} + </header> + ); + } + + return ( + <header + className={cn("px-6 h-14 flex justify-between items-center", className)} + > + {leftContent && leftContent} + {centerContent && centerContent} + {rightContent && rightContent} + </header> + ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 7d11e68a..2e6de534 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,47 +1,47 @@ -"use client";
-
-import Link from "next/link";
-import { Button } from "./ui/button";
-import { ArrowRight } from "lucide-react";
-import { HeaderBase } from "./header-base";
-import Image from "next/image";
-
-export function Header() {
- const leftContent = (
- <Link href="/" className="flex items-center gap-3">
- <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
- <span className="text-xl font-medium hidden md:block">OpenCut</span>
- </Link>
- );
-
- 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>
- <Link href="/projects">
- <Button size="sm" className="text-sm ml-4">
- Projects
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- </nav>
- );
-
- return (
- <div className="mx-4 md:mx-0">
- <HeaderBase
- className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
- leftContent={leftContent}
- rightContent={rightContent}
- />
- </div>
- );
-}
+"use client"; + +import Link from "next/link"; +import { Button } from "./ui/button"; +import { ArrowRight } from "lucide-react"; +import { HeaderBase } from "./header-base"; +import Image from "next/image"; + +export function Header() { + const leftContent = ( + <Link href="/" className="flex items-center gap-3"> + <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} /> + <span className="text-xl font-medium hidden md:block">OpenCut</span> + </Link> + ); + + 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> + <Link href="/projects"> + <Button size="sm" className="text-sm ml-4"> + Projects + <ArrowRight className="h-4 w-4" /> + </Button> + </Link> + </nav> + ); + + return ( + <div className="mx-4 md:mx-0"> + <HeaderBase + className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]" + leftContent={leftContent} + rightContent={rightContent} + /> + </div> + ); +} diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 68e8f0ff..6099c052 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -40,8 +40,15 @@ export function GithubIcon({ className }: { className?: string }) { export function VercelIcon({ className }: { className?: string }) { return ( - <svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/> + <svg + className={className} + width="20" + height="18" + viewBox="0 0 76 65" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" /> </svg> ); } @@ -105,4 +112,4 @@ export function BackgroundIcon({ className }: { className?: string }) { </defs> </svg> ); -}
\ No newline at end of file +} diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx index 36705e90..94764dbe 100644 --- a/apps/web/src/components/landing/handlebars.tsx +++ b/apps/web/src/components/landing/handlebars.tsx @@ -112,7 +112,7 @@ export function Handlebars({ whileDrag={{ scale: 1.1 }} transition={{ type: "spring", stiffness: 400, damping: 30 }} > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + <div className="w-2 h-8 rounded-full bg-yellow-500" /> </motion.div> <motion.div @@ -135,7 +135,7 @@ export function Handlebars({ whileDrag={{ scale: 1.1 }} transition={{ type: "spring", stiffness: 400, damping: 30 }} > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + <div className="w-2 h-8 rounded-full bg-yellow-500" /> </motion.div> </div> @@ -161,4 +161,4 @@ export function Handlebars({ </div> </div> ); -}; +} diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index 80b5f99c..1b43978e 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -1,121 +1,121 @@ -"use client";
-
-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";
-
-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;
- }
- };
-
- return (
- <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>
- );
-}
+"use client"; + +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"; + +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; + } + }; + + return ( + <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/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index d84bad71..c50f9f71 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -1,73 +1,73 @@ -import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function RenameProjectDialog({
- isOpen,
- onOpenChange,
- onConfirm,
- projectName,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: (name: string) => void;
- projectName: string;
-}) {
- const [name, setName] = useState(projectName);
-
- // Reset the name when dialog opens - this is better UX than syncing with every prop change
- const handleOpenChange = (open: boolean) => {
- if (open) {
- setName(projectName);
- }
- onOpenChange(open);
- };
-
- return (
- <Dialog open={isOpen} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Rename Project</DialogTitle>
- <DialogDescription>
- Enter a new name for your project.
- </DialogDescription>
- </DialogHeader>
-
- <Input
- value={name}
- onChange={(e) => setName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- onConfirm(name);
- }
- }}
- placeholder="Enter a new name"
- className="mt-4"
- />
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onOpenChange(false);
- }}
- >
- Cancel
- </Button>
- <Button onClick={() => onConfirm(name)}>Rename</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
+import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +export function RenameProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectName, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; + projectName: string; +}) { + const [name, setName] = useState(projectName); + + // Reset the name when dialog opens - this is better UX than syncing with every prop change + const handleOpenChange = (open: boolean) => { + if (open) { + setName(projectName); + } + onOpenChange(open); + }; + + return ( + <Dialog open={isOpen} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>Rename Project</DialogTitle> + <DialogDescription> + Enter a new name for your project. + </DialogDescription> + </DialogHeader> + + <Input + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="Enter a new name" + className="mt-4" + /> + + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button onClick={() => onConfirm(name)}>Rename</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index 48423b5c..69105d0f 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -1,80 +1,80 @@ -"use client";
-
-import { createContext, useContext, useEffect, useState } from "react";
-import { useProjectStore } from "@/stores/project-store";
-import { useMediaStore } from "@/stores/media-store";
-import { storageService } from "@/lib/storage/storage-service";
-import { toast } from "sonner";
-
-interface StorageContextType {
- isInitialized: boolean;
- isLoading: boolean;
- hasSupport: boolean;
- error: string | null;
-}
-
-const StorageContext = createContext<StorageContextType | null>(null);
-
-export function useStorage() {
- const context = useContext(StorageContext);
- if (!context) {
- throw new Error("useStorage must be used within StorageProvider");
- }
- return context;
-}
-
-interface StorageProviderProps {
- children: React.ReactNode;
-}
-
-export function StorageProvider({ children }: StorageProviderProps) {
- const [status, setStatus] = useState<StorageContextType>({
- isInitialized: false,
- isLoading: true,
- hasSupport: false,
- error: null,
- });
-
- const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
-
- useEffect(() => {
- const initializeStorage = async () => {
- setStatus((prev) => ({ ...prev, isLoading: true }));
-
- try {
- // Check browser support
- const hasSupport = storageService.isFullySupported();
-
- if (!hasSupport) {
- toast.warning(
- "Storage not fully supported. Some features may not work."
- );
- }
-
- // Load saved projects (media will be loaded when a project is loaded)
- await loadAllProjects();
-
- setStatus({
- isInitialized: true,
- isLoading: false,
- hasSupport,
- error: null,
- });
- } catch (error) {
- console.error("Failed to initialize storage:", error);
- setStatus({
- isInitialized: false,
- isLoading: false,
- hasSupport: storageService.isFullySupported(),
- error: error instanceof Error ? error.message : "Unknown error",
- });
- }
- };
-
- initializeStorage();
- }, [loadAllProjects]);
-
- return (
- <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
- );
-}
+"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; + +interface StorageContextType { + isInitialized: boolean; + isLoading: boolean; + hasSupport: boolean; + error: string | null; +} + +const StorageContext = createContext<StorageContextType | null>(null); + +export function useStorage() { + const context = useContext(StorageContext); + if (!context) { + throw new Error("useStorage must be used within StorageProvider"); + } + return context; +} + +interface StorageProviderProps { + children: React.ReactNode; +} + +export function StorageProvider({ children }: StorageProviderProps) { + const [status, setStatus] = useState<StorageContextType>({ + isInitialized: false, + isLoading: true, + hasSupport: false, + error: null, + }); + + const loadAllProjects = useProjectStore((state) => state.loadAllProjects); + + useEffect(() => { + const initializeStorage = async () => { + setStatus((prev) => ({ ...prev, isLoading: true })); + + try { + // Check browser support + const hasSupport = storageService.isFullySupported(); + + if (!hasSupport) { + toast.warning( + "Storage not fully supported. Some features may not work." + ); + } + + // Load saved projects (media will be loaded when a project is loaded) + await loadAllProjects(); + + setStatus({ + isInitialized: true, + isLoading: false, + hasSupport, + error: null, + }); + } catch (error) { + console.error("Failed to initialize storage:", error); + setStatus({ + isInitialized: false, + isLoading: false, + hasSupport: storageService.isFullySupported(), + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + initializeStorage(); + }, [loadAllProjects]); + + return ( + <StorageContext.Provider value={status}>{children}</StorageContext.Provider> + ); +} diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx index 6f90bbb8..082de9df 100644 --- a/apps/web/src/components/ui/aspect-ratio.tsx +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { AspectRatio as AspectRatioPrimitive } from "radix-ui" +import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; -const AspectRatio = AspectRatioPrimitive.Root +const AspectRatio = AspectRatioPrimitive.Root; -export { AspectRatio } +export { AspectRatio }; diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx index 774dd95c..37bf8ca9 100644 --- a/apps/web/src/components/ui/audio-player.tsx +++ b/apps/web/src/components/ui/audio-player.tsx @@ -1,127 +1,127 @@ -"use client";
-
-import { useRef, useEffect } from "react";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-interface AudioPlayerProps {
- src: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
- trackMuted?: boolean;
-}
-
-export function AudioPlayer({
- src,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration,
- trackMuted = false,
-}: AudioPlayerProps) {
- const audioRef = useRef<HTMLAudioElement>(null);
- const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
-
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange =
- currentTime >= clipStartTime && currentTime < clipEndTime;
-
- // Sync playback events
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio || !isInClipRange) return;
-
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const audioTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
- audio.currentTime = audioTime;
- };
-
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
-
- if (Math.abs(audio.currentTime - targetTime) > 0.5) {
- audio.currentTime = targetTime;
- }
- };
-
- const handleSpeed = (e: CustomEvent) => {
- audio.playbackRate = e.detail.speed;
- };
-
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.addEventListener("playback-speed", handleSpeed as EventListener);
-
- return () => {
- window.removeEventListener(
- "playback-seek",
- handleSeekEvent as EventListener
- );
- window.removeEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.removeEventListener(
- "playback-speed",
- handleSpeed as EventListener
- );
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
-
- // Sync playback state
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- if (isPlaying && isInClipRange && !trackMuted) {
- audio.play().catch(() => {});
- } else {
- audio.pause();
- }
- }, [isPlaying, isInClipRange, trackMuted]);
-
- // Sync volume and speed
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- audio.volume = volume;
- audio.muted = muted || trackMuted;
- audio.playbackRate = speed;
- }, [volume, speed, muted, trackMuted]);
-
- return (
- <audio
- ref={audioRef}
- src={src}
- className={className}
- preload="auto"
- controls={false}
- style={{ display: "none" }} // Audio elements don't need visual representation
- onContextMenu={(e) => e.preventDefault()}
- />
- );
-}
+"use client"; + +import { useRef, useEffect } from "react"; +import { usePlaybackStore } from "@/stores/playback-store"; + +interface AudioPlayerProps { + src: string; + className?: string; + clipStartTime: number; + trimStart: number; + trimEnd: number; + clipDuration: number; + trackMuted?: boolean; +} + +export function AudioPlayer({ + src, + className = "", + clipStartTime, + trimStart, + trimEnd, + clipDuration, + trackMuted = false, +}: AudioPlayerProps) { + const audioRef = useRef<HTMLAudioElement>(null); + const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + + // Calculate if we're within this clip's timeline range + const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); + const isInClipRange = + currentTime >= clipStartTime && currentTime < clipEndTime; + + // Sync playback events + useEffect(() => { + const audio = audioRef.current; + if (!audio || !isInClipRange) return; + + const handleSeekEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const audioTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); + audio.currentTime = audioTime; + }; + + const handleUpdateEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const targetTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); + + if (Math.abs(audio.currentTime - targetTime) > 0.5) { + audio.currentTime = targetTime; + } + }; + + const handleSpeed = (e: CustomEvent) => { + audio.playbackRate = e.detail.speed; + }; + + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.addEventListener("playback-speed", handleSpeed as EventListener); + + return () => { + window.removeEventListener( + "playback-seek", + handleSeekEvent as EventListener + ); + window.removeEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.removeEventListener( + "playback-speed", + handleSpeed as EventListener + ); + }; + }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); + + // Sync playback state + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying && isInClipRange && !trackMuted) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + }, [isPlaying, isInClipRange, trackMuted]); + + // Sync volume and speed + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.volume = volume; + audio.muted = muted || trackMuted; + audio.playbackRate = speed; + }, [volume, speed, muted, trackMuted]); + + return ( + <audio + ref={audioRef} + src={src} + className={className} + preload="auto" + controls={false} + style={{ display: "none" }} // Audio elements don't need visual representation + onContextMenu={(e) => e.preventDefault()} + /> + ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index b8b9ab2f..5902af65 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -9,8 +9,7 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-foreground text-background shadow hover:bg-foreground/90", + default: "bg-foreground text-background shadow hover:bg-foreground/90", primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index c5ca9d9f..6d0b37cc 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -8,10 +8,7 @@ const Card = React.forwardRef< >(({ className, ...props }, ref) => ( <div ref={ref} - className={cn( - "rounded-xl border bg-card text-card-foreground", - className - )} + className={cn("rounded-xl border bg-card text-card-foreground", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx index 3c0efdc6..a9c98b67 100644 --- a/apps/web/src/components/ui/carousel.tsx +++ b/apps/web/src/components/ui/carousel.tsx @@ -124,7 +124,7 @@ const Carousel = React.forwardRef< <CarouselContext.Provider value={{ carouselRef, - api: api, + api, opts, orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx index a2c19116..82d77752 100644 --- a/apps/web/src/components/ui/chart.tsx +++ b/apps/web/src/components/ui/chart.tsx @@ -188,7 +188,7 @@ const ChartTooltipContent = React.forwardRef< className )} > - {!nestLabel ? tooltipLabel : null} + {nestLabel ? null : tooltipLabel} <div className="grid gap-1.5"> {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; @@ -328,7 +328,7 @@ function getPayloadConfigFromPayload( key: string ) { if (typeof payload !== "object" || payload === null) { - return undefined; + return; } const payloadPayload = diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index 6642d604..acb56414 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import { Collapsible as CollapsiblePrimitive } from "radix-ui" +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; -const Collapsible = CollapsiblePrimitive.Root +const Collapsible = CollapsiblePrimitive.Root; -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 9a152fd4..479a5f0c 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,40 +1,40 @@ -import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
-
-interface FontPickerProps {
- defaultValue?: FontFamily;
- onValueChange?: (value: FontFamily) => void;
- className?: string;
-}
-
-export function FontPicker({
- defaultValue,
- onValueChange,
- className,
-}: FontPickerProps) {
- return (
- <Select defaultValue={defaultValue} onValueChange={onValueChange}>
- <SelectTrigger className={`w-full text-xs ${className || ""}`}>
- <SelectValue placeholder="Select a font" />
- </SelectTrigger>
- <SelectContent>
- {FONT_OPTIONS.map((font) => (
- <SelectItem
- key={font.value}
- value={font.value}
- className="text-xs"
- style={{ fontFamily: font.value }}
- >
- {font.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
+import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants"; + +interface FontPickerProps { + defaultValue?: FontFamily; + onValueChange?: (value: FontFamily) => void; + className?: string; +} + +export function FontPicker({ + defaultValue, + onValueChange, + className, +}: FontPickerProps) { + return ( + <Select defaultValue={defaultValue} onValueChange={onValueChange}> + <SelectTrigger className={`w-full text-xs ${className || ""}`}> + <SelectValue placeholder="Select a font" /> + </SelectTrigger> + <SelectContent> + {FONT_OPTIONS.map((font) => ( + <SelectItem + key={font.value} + value={font.value} + className="text-xs" + style={{ fontFamily: font.value }} + > + {font.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx index cb4f53a9..cab151e1 100644 --- a/apps/web/src/components/ui/form.tsx +++ b/apps/web/src/components/ui/form.tsx @@ -115,9 +115,7 @@ const FormControl = React.forwardRef< ref={ref} id={formItemId} aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` + error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}` } aria-invalid={!!error} {...props} diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx index 31847df5..7a64be18 100644 --- a/apps/web/src/components/ui/image-timeline-treatment.tsx +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -1,102 +1,102 @@ -"use client";
-
-import { useState } from "react";
-import { cn } from "@/lib/utils";
-import type { BackgroundType } from "@/types/editor";
-
-interface ImageTimelineTreatmentProps {
- src: string;
- alt: string;
- targetAspectRatio?: number; // Default to 16:9 for video
- className?: string;
- backgroundType?: BackgroundType;
- backgroundColor?: string;
-}
-
-export function ImageTimelineTreatment({
- src,
- alt,
- targetAspectRatio = 16 / 9,
- className,
- backgroundType = "blur",
- backgroundColor = "#000000",
-}: ImageTimelineTreatmentProps) {
- const [imageLoaded, setImageLoaded] = useState(false);
- const [imageDimensions, setImageDimensions] = useState<{
- width: number;
- height: number;
- } | null>(null);
-
- const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- setImageLoaded(true);
- };
-
- const imageAspectRatio = imageDimensions
- ? imageDimensions.width / imageDimensions.height
- : 1;
-
- const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
-
- return (
- <div
- className={cn("relative overflow-hidden", className)}
- style={{ aspectRatio: targetAspectRatio }}
- >
- {/* Background Layer */}
- {needsAspectRatioTreatment && imageLoaded && (
- <>
- {backgroundType === "blur" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
- aria-hidden="true"
- />
- <div className="absolute inset-0 bg-black/20" />
- </div>
- )}
-
- {backgroundType === "mirror" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover opacity-30"
- aria-hidden="true"
- />
- </div>
- )}
-
- {backgroundType === "color" && (
- <div className="absolute inset-0" style={{ backgroundColor }} />
- )}
- </>
- )}
-
- {/* Main Image Layer */}
- <div className="absolute inset-0">
- <img
- src={src}
- alt={alt}
- className="w-full h-full object-cover"
- onLoad={handleImageLoad}
- />
- </div>
-
- {/* Loading state */}
- {!imageLoaded && (
- <div className="absolute inset-0 flex items-center justify-center bg-muted/30">
- <div className="animate-pulse text-xs text-muted-foreground">
- Loading...
- </div>
- </div>
- )}
- </div>
- );
-}
+"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { BackgroundType } from "@/types/editor"; + +interface ImageTimelineTreatmentProps { + src: string; + alt: string; + targetAspectRatio?: number; // Default to 16:9 for video + className?: string; + backgroundType?: BackgroundType; + backgroundColor?: string; +} + +export function ImageTimelineTreatment({ + src, + alt, + targetAspectRatio = 16 / 9, + className, + backgroundType = "blur", + backgroundColor = "#000000", +}: ImageTimelineTreatmentProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setImageLoaded(true); + }; + + const imageAspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : 1; + + const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; + + return ( + <div + className={cn("relative overflow-hidden", className)} + style={{ aspectRatio: targetAspectRatio }} + > + {/* Background Layer */} + {needsAspectRatioTreatment && imageLoaded && ( + <> + {backgroundType === "blur" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover filter blur-xl scale-110 opacity-60" + aria-hidden="true" + /> + <div className="absolute inset-0 bg-black/20" /> + </div> + )} + + {backgroundType === "mirror" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover opacity-30" + aria-hidden="true" + /> + </div> + )} + + {backgroundType === "color" && ( + <div className="absolute inset-0" style={{ backgroundColor }} /> + )} + </> + )} + + {/* Main Image Layer */} + <div className="absolute inset-0"> + <img + src={src} + alt={alt} + className="w-full h-full object-cover" + onLoad={handleImageLoad} + /> + </div> + + {/* Loading state */} + {!imageLoaded && ( + <div className="absolute inset-0 flex items-center justify-center bg-muted/30"> + <div className="animate-pulse text-xs text-muted-foreground"> + Loading... + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx index 95ad9131..ca85df8f 100644 --- a/apps/web/src/components/ui/phone-input.tsx +++ b/apps/web/src/components/ui/phone-input.tsx @@ -1,167 +1,167 @@ -import * as React from "react";
-import { CheckIcon, ChevronsUpDown } from "lucide-react";
-import * as RPNInput from "react-phone-number-input";
-import flags from "react-phone-number-input/flags";
-
-import { Button } from "./button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "./command";
-import { Input } from "./input";
-import { Popover, PopoverContent, PopoverTrigger } from "./popover";
-import { ScrollArea } from "./scroll-area";
-import { cn } from "../../lib/utils";
-
-type PhoneInputProps = Omit<
- React.ComponentProps<"input">,
- "onChange" | "value" | "ref"
-> &
- Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
- onChange?: (value: RPNInput.Value) => void;
- };
-
-const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
- React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
- ({ className, onChange, defaultCountry = "US", ...props }, ref) => {
- return (
- <RPNInput.default
- ref={ref}
- className={cn("flex", className)}
- flagComponent={FlagComponent}
- countrySelectComponent={CountrySelect}
- inputComponent={InputComponent}
- defaultCountry={defaultCountry}
- international
- addInternationalOption
- smartCaret={false}
- /**
- * Handles the onChange event.
- *
- * react-phone-number-input might trigger the onChange event as undefined
- * when a valid phone number is not entered. To prevent this,
- * the value is coerced to an empty string.
- *
- * @param {E164Number | undefined} value - The entered value
- */
- onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
- {...props}
- />
- );
- }
- );
-PhoneInput.displayName = "PhoneInput";
-
-const InputComponent = React.forwardRef<
- HTMLInputElement,
- React.ComponentProps<"input">
->(({ className, ...props }, ref) => (
- <Input
- className={cn("rounded-e-lg rounded-s-none", className)}
- {...props}
- ref={ref}
- />
-));
-InputComponent.displayName = "InputComponent";
-
-type CountryEntry = { label: string; value: RPNInput.Country | undefined };
-
-type CountrySelectProps = {
- disabled?: boolean;
- value: RPNInput.Country;
- options: CountryEntry[];
- onChange: (country: RPNInput.Country) => void;
-};
-
-const CountrySelect = ({
- disabled,
- value: selectedCountry,
- options: countryList,
- onChange,
-}: CountrySelectProps) => {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
- disabled={disabled}
- >
- <FlagComponent
- country={selectedCountry}
- countryName={selectedCountry}
- />
- <ChevronsUpDown
- className={cn(
- "-mr-2 size-4 opacity-50",
- disabled ? "hidden" : "opacity-100"
- )}
- />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[300px] p-0">
- <Command>
- <CommandInput placeholder="Search country..." />
- <CommandList>
- <ScrollArea className="h-72">
- <CommandEmpty>No country found.</CommandEmpty>
- <CommandGroup>
- {countryList.map(({ value, label }) =>
- value ? (
- <CountrySelectOption
- key={value}
- country={value}
- countryName={label}
- selectedCountry={selectedCountry}
- onChange={onChange}
- />
- ) : null
- )}
- </CommandGroup>
- </ScrollArea>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-};
-
-interface CountrySelectOptionProps extends RPNInput.FlagProps {
- selectedCountry: RPNInput.Country;
- onChange: (country: RPNInput.Country) => void;
-}
-
-const CountrySelectOption = ({
- country,
- countryName,
- selectedCountry,
- onChange,
-}: CountrySelectOptionProps) => {
- return (
- <CommandItem className="gap-2" onSelect={() => onChange(country)}>
- <FlagComponent country={country} countryName={countryName} />
- <span className="flex-1 text-sm">{countryName}</span>
- <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
- <CheckIcon
- className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
- />
- </CommandItem>
- );
-};
-
-const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
- const Flag = flags[country];
-
- return (
- <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
- {Flag && <Flag title={countryName} />}
- </span>
- );
-};
-
-export { PhoneInput };
+import * as React from "react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import * as RPNInput from "react-phone-number-input"; +import flags from "react-phone-number-input/flags"; + +import { Button } from "./button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./command"; +import { Input } from "./input"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { ScrollArea } from "./scroll-area"; +import { cn } from "../../lib/utils"; + +type PhoneInputProps = Omit< + React.ComponentProps<"input">, + "onChange" | "value" | "ref" +> & + Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { + onChange?: (value: RPNInput.Value) => void; + }; + +const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = + React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( + ({ className, onChange, defaultCountry = "US", ...props }, ref) => { + return ( + <RPNInput.default + ref={ref} + className={cn("flex", className)} + flagComponent={FlagComponent} + countrySelectComponent={CountrySelect} + inputComponent={InputComponent} + defaultCountry={defaultCountry} + international + addInternationalOption + smartCaret={false} + /** + * Handles the onChange event. + * + * react-phone-number-input might trigger the onChange event as undefined + * when a valid phone number is not entered. To prevent this, + * the value is coerced to an empty string. + * + * @param {E164Number | undefined} value - The entered value + */ + onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} + {...props} + /> + ); + } + ); +PhoneInput.displayName = "PhoneInput"; + +const InputComponent = React.forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, ...props }, ref) => ( + <Input + className={cn("rounded-e-lg rounded-s-none", className)} + {...props} + ref={ref} + /> +)); +InputComponent.displayName = "InputComponent"; + +type CountryEntry = { label: string; value: RPNInput.Country | undefined }; + +type CountrySelectProps = { + disabled?: boolean; + value: RPNInput.Country; + options: CountryEntry[]; + onChange: (country: RPNInput.Country) => void; +}; + +const CountrySelect = ({ + disabled, + value: selectedCountry, + options: countryList, + onChange, +}: CountrySelectProps) => { + return ( + <Popover> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10" + disabled={disabled} + > + <FlagComponent + country={selectedCountry} + countryName={selectedCountry} + /> + <ChevronsUpDown + className={cn( + "-mr-2 size-4 opacity-50", + disabled ? "hidden" : "opacity-100" + )} + /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <ScrollArea className="h-72"> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {countryList.map(({ value, label }) => + value ? ( + <CountrySelectOption + key={value} + country={value} + countryName={label} + selectedCountry={selectedCountry} + onChange={onChange} + /> + ) : null + )} + </CommandGroup> + </ScrollArea> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}; + +interface CountrySelectOptionProps extends RPNInput.FlagProps { + selectedCountry: RPNInput.Country; + onChange: (country: RPNInput.Country) => void; +} + +const CountrySelectOption = ({ + country, + countryName, + selectedCountry, + onChange, +}: CountrySelectOptionProps) => { + return ( + <CommandItem className="gap-2" onSelect={() => onChange(country)}> + <FlagComponent country={country} countryName={countryName} /> + <span className="flex-1 text-sm">{countryName}</span> + <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span> + <CheckIcon + className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} + /> + </CommandItem> + ); +}; + +const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { + const Flag = flags[country]; + + return ( + <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full"> + {Flag && <Flag title={countryName} />} + </span> + ); +}; + +export { PhoneInput }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx index 12d81c4a..5b6774dc 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 * as SeparatorPrimitive from "@radix-ui/react-separator" +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/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 452f4d9f..549cf841 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,12 +1,12 @@ -"use client" +"use client"; -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; -type ToasterProps = React.ComponentProps<typeof Sonner> +type ToasterProps = React.ComponentProps<typeof Sonner>; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); return ( <Sonner @@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => { }} {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/apps/web/src/components/ui/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx index 79c708a2..a060e428 100644 --- a/apps/web/src/components/ui/sponsor-button.tsx +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -14,7 +14,6 @@ export function SponsorButton({ companyName, className = "", }: SponsorButtonProps) { - return ( <motion.a href={href} diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index 3eb8a720..baf623f0 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -15,20 +15,16 @@ export function Toaster() { return ( <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} + {toasts.map(({ id, title, description, action, ...props }) => ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && <ToastDescription>{description}</ToastDescription>} + </div> + {action} + <ToastClose /> + </Toast> + ))} <ToastViewport /> </ToastProvider> ); diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 5f1883f1..4278ce7e 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -191,7 +191,7 @@ export function isActionBound(action: Action): boolean { export function useActionHandler<A extends Action>( action: A, handler: ActionFunc<A>, - isActive: MutableRefObject<boolean> | boolean | undefined = undefined + isActive: MutableRefObject<boolean> | boolean | undefined ) { const handlerRef = useRef(handler); const [isBound, setIsBound] = useState(false); diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts index bfddc182..a1c4e4d5 100644 --- a/apps/web/src/constants/font-constants.ts +++ b/apps/web/src/constants/font-constants.ts @@ -1,79 +1,79 @@ -export interface FontOption {
- value: string;
- label: string;
- category: "system" | "google" | "custom";
- weights?: number[];
- hasClassName?: boolean;
-}
-
-export const FONT_OPTIONS: FontOption[] = [
- // System fonts (always available)
- { value: "Arial", label: "Arial", category: "system", hasClassName: false },
- {
- value: "Helvetica",
- label: "Helvetica",
- category: "system",
- hasClassName: false,
- },
- {
- value: "Times New Roman",
- label: "Times New Roman",
- category: "system",
- hasClassName: false,
- },
- {
- value: "Georgia",
- label: "Georgia",
- category: "system",
- hasClassName: false,
- },
-
- // Google Fonts (loaded in layout.tsx)
- {
- value: "Inter",
- label: "Inter",
- category: "google",
- weights: [400, 700],
- hasClassName: true,
- },
- {
- value: "Roboto",
- label: "Roboto",
- category: "google",
- weights: [400, 700],
- hasClassName: true,
- },
- {
- value: "Open Sans",
- label: "Open Sans",
- category: "google",
- hasClassName: true,
- },
- {
- value: "Playfair Display",
- label: "Playfair Display",
- category: "google",
- hasClassName: true,
- },
- {
- value: "Comic Neue",
- label: "Comic Neue",
- category: "google",
- hasClassName: false,
- },
-] as const;
-
-export const DEFAULT_FONT = "Arial";
-
-// Type-safe font family union
-export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
-
-// Helper functions
-export const getFontByValue = (value: string): FontOption | undefined =>
- FONT_OPTIONS.find((font) => font.value === value);
-
-export const getGoogleFonts = (): FontOption[] =>
- FONT_OPTIONS.filter((font) => font.category === "google");
-
-export const getSystemFonts = (): FontOption[] =>
- FONT_OPTIONS.filter((font) => font.category === "system");
+export interface FontOption { + value: string; + label: string; + category: "system" | "google" | "custom"; + weights?: number[]; + hasClassName?: boolean; +} + +export const FONT_OPTIONS: FontOption[] = [ + // System fonts (always available) + { value: "Arial", label: "Arial", category: "system", hasClassName: false }, + { + value: "Helvetica", + label: "Helvetica", + category: "system", + hasClassName: false, + }, + { + value: "Times New Roman", + label: "Times New Roman", + category: "system", + hasClassName: false, + }, + { + value: "Georgia", + label: "Georgia", + category: "system", + hasClassName: false, + }, + + // Google Fonts (loaded in layout.tsx) + { + value: "Inter", + label: "Inter", + category: "google", + weights: [400, 700], + hasClassName: true, + }, + { + value: "Roboto", + label: "Roboto", + category: "google", + weights: [400, 700], + hasClassName: true, + }, + { + value: "Open Sans", + label: "Open Sans", + category: "google", + hasClassName: true, + }, + { + value: "Playfair Display", + label: "Playfair Display", + category: "google", + hasClassName: true, + }, + { + value: "Comic Neue", + label: "Comic Neue", + category: "google", + hasClassName: false, + }, +] as const; + +export const DEFAULT_FONT = "Arial"; + +// Type-safe font family union +export type FontFamily = (typeof FONT_OPTIONS)[number]["value"]; + +// Helper functions +export const getFontByValue = (value: string): FontOption | undefined => + FONT_OPTIONS.find((font) => font.value === value); + +export const getGoogleFonts = (): FontOption[] => + FONT_OPTIONS.filter((font) => font.category === "google"); + +export const getSystemFonts = (): FontOption[] => + FONT_OPTIONS.filter((font) => font.category === "system"); diff --git a/apps/web/src/constants/site.ts b/apps/web/src/constants/site.ts index 61ec29c1..f47f6470 100644 --- a/apps/web/src/constants/site.ts +++ b/apps/web/src/constants/site.ts @@ -1 +1,11 @@ -export const SITE_URL = "https://opencut.app";
\ No newline at end of file +export const SITE_URL = "https://opencut.app"; + +export const SITE_INFO = { + title: "OpenCut", + description: + "A simple but powerful video editor that gets the job done. In your browser.", + url: SITE_URL, + openGraphImage: "/open-graph/default.jpg", + twitterImage: "/open-graph/default.jpg", + favicon: "/favicon.ico", +}; diff --git a/apps/web/src/data/colors.ts b/apps/web/src/data/colors.ts index 3ef094d6..9a12a587 100644 --- a/apps/web/src/data/colors.ts +++ b/apps/web/src/data/colors.ts @@ -1,244 +1,244 @@ -export const colors = [
- "#ffffff",
- "#000000",
- "#ffe2e2",
- "#ffc9c9",
- "#ffa2a2",
- "#ff6467",
- "#fb2c36",
- "#e7000b",
- "#c10007",
- "#9f0712",
- "#82181a",
- "#460809",
- "#fff7ed",
- "#ffedd4",
- "#ffd6a7",
- "#ffb86a",
- "#ff8904",
- "#ff6900",
- "#f54900",
- "#ca3500",
- "#9f2d00",
- "#7e2a0c",
- "#441306",
- "#fffbeb",
- "#fef3c6",
- "#fee685",
- "#ffd230",
- "#ffb900",
- "#fe9a00",
- "#e17100",
- "#bb4d00",
- "#973c00",
- "#7b3306",
- "#461901",
- "#fefce8",
- "#fef9c2",
- "#fff085",
- "#ffdf20",
- "#fdc700",
- "#f0b100",
- "#d08700",
- "#a65f00",
- "#894b00",
- "#733e0a",
- "#432004",
- "#f7fee7",
- "#ecfcca",
- "#d8f999",
- "#bbf451",
- "#9ae600",
- "#7ccf00",
- "#5ea500",
- "#497d00",
- "#3c6300",
- "#35530e",
- "#192e03",
- "#f0fdf4",
- "#dcfce7",
- "#b9f8cf",
- "#7bf1a8",
- "#05df72",
- "#00c950",
- "#00a63e",
- "#008236",
- "#016630",
- "#0d542b",
- "#032e15",
- "#ecfdf5",
- "#d0fae5",
- "#a4f4cf",
- "#5ee9b5",
- "#00d492",
- "#00bc7d",
- "#009966",
- "#007a55",
- "#006045",
- "#004f3b",
- "#002c22",
- "#f0fdfa",
- "#cbfbf1",
- "#96f7e4",
- "#46ecd5",
- "#00d5be",
- "#00bba7",
- "#009689",
- "#00786f",
- "#005f5a",
- "#0b4f4a",
- "#022f2e",
- "#ecfeff",
- "#cefafe",
- "#a2f4fd",
- "#53eafd",
- "#00d3f2",
- "#00b8db",
- "#0092b8",
- "#007595",
- "#005f78",
- "#104e64",
- "#053345",
- "#f0f9ff",
- "#dff2fe",
- "#b8e6fe",
- "#74d4ff",
- "#00bcff",
- "#00a6f4",
- "#0084d1",
- "#0069a8",
- "#00598a",
- "#024a70",
- "#052f4a",
- "#eff6ff",
- "#dbeafe",
- "#bedbff",
- "#8ec5ff",
- "#51a2ff",
- "#2b7fff",
- "#155dfc",
- "#1447e6",
- "#193cb8",
- "#1c398e",
- "#162456",
- "#eef2ff",
- "#e0e7ff",
- "#c6d2ff",
- "#a3b3ff",
- "#7c86ff",
- "#615fff",
- "#4f39f6",
- "#432dd7",
- "#372aac",
- "#312c85",
- "#1e1a4d",
- "#f5f3ff",
- "#ede9fe",
- "#ddd6ff",
- "#c4b4ff",
- "#a684ff",
- "#8e51ff",
- "#7f22fe",
- "#7008e7",
- "#5d0ec0",
- "#4d179a",
- "#2f0d68",
- "#faf5ff",
- "#f3e8ff",
- "#e9d4ff",
- "#dab2ff",
- "#c27aff",
- "#ad46ff",
- "#9810fa",
- "#8200db",
- "#6e11b0",
- "#59168b",
- "#3c0366",
- "#fdf4ff",
- "#fae8ff",
- "#f6cfff",
- "#f4a8ff",
- "#ed6aff",
- "#e12afb",
- "#c800de",
- "#a800b7",
- "#8a0194",
- "#721378",
- "#4b004f",
- "#fdf2f8",
- "#fce7f3",
- "#fccee8",
- "#fda5d5",
- "#fb64b6",
- "#f6339a",
- "#e60076",
- "#c6005c",
- "#a3004c",
- "#861043",
- "#510424",
- "#fff1f2",
- "#ffe4e6",
- "#ffccd3",
- "#ffa1ad",
- "#ff637e",
- "#ff2056",
- "#ec003f",
- "#c70036",
- "#a50036",
- "#8b0836",
- "#4d0218",
- "#f8fafc",
- "#f1f5f9",
- "#e2e8f0",
- "#cad5e2",
- "#90a1b9",
- "#62748e",
- "#45556c",
- "#314158",
- "#1d293d",
- "#0f172b",
- "#020618",
- "#f9fafb",
- "#f3f4f6",
- "#e5e7eb",
- "#d1d5dc",
- "#99a1af",
- "#6a7282",
- "#4a5565",
- "#364153",
- "#1e2939",
- "#101828",
- "#030712",
- "#fafafa",
- "#f4f4f5",
- "#e4e4e7",
- "#d4d4d8",
- "#9f9fa9",
- "#71717b",
- "#52525c",
- "#3f3f46",
- "#27272a",
- "#18181b",
- "#09090b",
- "#f5f5f5",
- "#e5e5e5",
- "#d4d4d4",
- "#a1a1a1",
- "#737373",
- "#525252",
- "#404040",
- "#262626",
- "#171717",
- "#0a0a0a",
- "#fafaf9",
- "#f5f5f4",
- "#e7e5e4",
- "#d6d3d1",
- "#a6a09b",
- "#79716b",
- "#57534d",
- "#44403b",
- "#292524",
- "#1c1917",
- "#0c0a09",
-];
+export const colors = [ + "#ffffff", + "#000000", + "#ffe2e2", + "#ffc9c9", + "#ffa2a2", + "#ff6467", + "#fb2c36", + "#e7000b", + "#c10007", + "#9f0712", + "#82181a", + "#460809", + "#fff7ed", + "#ffedd4", + "#ffd6a7", + "#ffb86a", + "#ff8904", + "#ff6900", + "#f54900", + "#ca3500", + "#9f2d00", + "#7e2a0c", + "#441306", + "#fffbeb", + "#fef3c6", + "#fee685", + "#ffd230", + "#ffb900", + "#fe9a00", + "#e17100", + "#bb4d00", + "#973c00", + "#7b3306", + "#461901", + "#fefce8", + "#fef9c2", + "#fff085", + "#ffdf20", + "#fdc700", + "#f0b100", + "#d08700", + "#a65f00", + "#894b00", + "#733e0a", + "#432004", + "#f7fee7", + "#ecfcca", + "#d8f999", + "#bbf451", + "#9ae600", + "#7ccf00", + "#5ea500", + "#497d00", + "#3c6300", + "#35530e", + "#192e03", + "#f0fdf4", + "#dcfce7", + "#b9f8cf", + "#7bf1a8", + "#05df72", + "#00c950", + "#00a63e", + "#008236", + "#016630", + "#0d542b", + "#032e15", + "#ecfdf5", + "#d0fae5", + "#a4f4cf", + "#5ee9b5", + "#00d492", + "#00bc7d", + "#009966", + "#007a55", + "#006045", + "#004f3b", + "#002c22", + "#f0fdfa", + "#cbfbf1", + "#96f7e4", + "#46ecd5", + "#00d5be", + "#00bba7", + "#009689", + "#00786f", + "#005f5a", + "#0b4f4a", + "#022f2e", + "#ecfeff", + "#cefafe", + "#a2f4fd", + "#53eafd", + "#00d3f2", + "#00b8db", + "#0092b8", + "#007595", + "#005f78", + "#104e64", + "#053345", + "#f0f9ff", + "#dff2fe", + "#b8e6fe", + "#74d4ff", + "#00bcff", + "#00a6f4", + "#0084d1", + "#0069a8", + "#00598a", + "#024a70", + "#052f4a", + "#eff6ff", + "#dbeafe", + "#bedbff", + "#8ec5ff", + "#51a2ff", + "#2b7fff", + "#155dfc", + "#1447e6", + "#193cb8", + "#1c398e", + "#162456", + "#eef2ff", + "#e0e7ff", + "#c6d2ff", + "#a3b3ff", + "#7c86ff", + "#615fff", + "#4f39f6", + "#432dd7", + "#372aac", + "#312c85", + "#1e1a4d", + "#f5f3ff", + "#ede9fe", + "#ddd6ff", + "#c4b4ff", + "#a684ff", + "#8e51ff", + "#7f22fe", + "#7008e7", + "#5d0ec0", + "#4d179a", + "#2f0d68", + "#faf5ff", + "#f3e8ff", + "#e9d4ff", + "#dab2ff", + "#c27aff", + "#ad46ff", + "#9810fa", + "#8200db", + "#6e11b0", + "#59168b", + "#3c0366", + "#fdf4ff", + "#fae8ff", + "#f6cfff", + "#f4a8ff", + "#ed6aff", + "#e12afb", + "#c800de", + "#a800b7", + "#8a0194", + "#721378", + "#4b004f", + "#fdf2f8", + "#fce7f3", + "#fccee8", + "#fda5d5", + "#fb64b6", + "#f6339a", + "#e60076", + "#c6005c", + "#a3004c", + "#861043", + "#510424", + "#fff1f2", + "#ffe4e6", + "#ffccd3", + "#ffa1ad", + "#ff637e", + "#ff2056", + "#ec003f", + "#c70036", + "#a50036", + "#8b0836", + "#4d0218", + "#f8fafc", + "#f1f5f9", + "#e2e8f0", + "#cad5e2", + "#90a1b9", + "#62748e", + "#45556c", + "#314158", + "#1d293d", + "#0f172b", + "#020618", + "#f9fafb", + "#f3f4f6", + "#e5e7eb", + "#d1d5dc", + "#99a1af", + "#6a7282", + "#4a5565", + "#364153", + "#1e2939", + "#101828", + "#030712", + "#fafafa", + "#f4f4f5", + "#e4e4e7", + "#d4d4d8", + "#9f9fa9", + "#71717b", + "#52525c", + "#3f3f46", + "#27272a", + "#18181b", + "#09090b", + "#f5f5f5", + "#e5e5e5", + "#d4d4d4", + "#a1a1a1", + "#737373", + "#525252", + "#404040", + "#262626", + "#171717", + "#0a0a0a", + "#fafaf9", + "#f5f5f4", + "#e7e5e4", + "#d6d3d1", + "#a6a09b", + "#79716b", + "#57534d", + "#44403b", + "#292524", + "#1c1917", + "#0c0a09", +]; diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts index 12eddcbf..b2c5ef98 100644 --- a/apps/web/src/hooks/auth/useLogin.ts +++ b/apps/web/src/hooks/auth/useLogin.ts @@ -3,58 +3,58 @@ import { useRouter } from "next/navigation"; import { signIn } from "@opencut/auth/client"; export function useLogin() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleLogin = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleLogin = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signIn.email({ - email, - password, - }); + const { error } = await signIn.email({ + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/projects"); - }, [router, email, password]); + router.push("/projects"); + }, [router, email, password]); - const handleGoogleLogin = async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleLogin = async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - callbackURL: "/projects", - }); - } catch (error) { - setError("Failed to sign in with Google. Please try again."); - setIsGoogleLoading(false); - } - }; + try { + await signIn.social({ + provider: "google", + callbackURL: "/projects", + }); + } catch (error) { + setError("Failed to sign in with Google. Please try again."); + setIsGoogleLoading(false); + } + }; - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleLogin, - handleGoogleLogin, - }; + return { + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleLogin, + handleGoogleLogin, + }; } diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts index 4cd2850a..b6039f85 100644 --- a/apps/web/src/hooks/auth/useSignUp.ts +++ b/apps/web/src/hooks/auth/useSignUp.ts @@ -3,63 +3,63 @@ import { useRouter } from "next/navigation"; import { signUp, signIn } from "@opencut/auth/client"; export function useSignUp() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleSignUp = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleSignUp = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signUp.email({ - name, - email, - password, - }); + const { error } = await signUp.email({ + name, + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/login"); - }, [name, email, password, router]); + router.push("/login"); + }, [name, email, password, router]); - const handleGoogleSignUp = useCallback(async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleSignUp = useCallback(async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - }); + try { + await signIn.social({ + provider: "google", + }); - router.push("/editor"); - } catch (error) { - setError("Failed to sign up with Google. Please try again."); - setIsGoogleLoading(false); - } - }, [router]); + router.push("/editor"); + } catch (error) { + setError("Failed to sign up with Google. Please try again."); + setIsGoogleLoading(false); + } + }, [router]); - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleSignUp, - handleGoogleSignUp, - }; -}
\ No newline at end of file + return { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleSignUp, + handleGoogleSignUp, + }; +} diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index 40de8454..e3e85141 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -1,92 +1,92 @@ -import { useEditorStore } from "@/stores/editor-store";
-import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-
-export function useAspectRatio() {
- const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
- const { mediaItems } = useMediaStore();
- const { tracks } = useTimelineStore();
-
- // Find the current preset based on canvas size
- const currentPreset = canvasPresets.find(
- (preset) =>
- preset.width === canvasSize.width && preset.height === canvasSize.height
- );
-
- // Get the original aspect ratio from the first video/image in timeline
- const getOriginalAspectRatio = (): number => {
- // Find first video or image in timeline
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaItem = mediaItems.find(
- (item) => item.id === element.mediaId
- );
- if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- ) {
- return getMediaAspectRatio(mediaItem);
- }
- }
- }
- }
- return 16 / 9; // Default aspect ratio
- };
-
- // Get current aspect ratio
- const getCurrentAspectRatio = (): number => {
- return canvasSize.width / canvasSize.height;
- };
-
- // Format aspect ratio as a readable string
- const formatAspectRatio = (aspectRatio: number): string => {
- // Check if it matches a common aspect ratio
- const ratios = [
- { ratio: 16 / 9, label: "16:9" },
- { ratio: 9 / 16, label: "9:16" },
- { ratio: 1, label: "1:1" },
- { ratio: 4 / 3, label: "4:3" },
- { ratio: 3 / 4, label: "3:4" },
- { ratio: 21 / 9, label: "21:9" },
- ];
-
- for (const { ratio, label } of ratios) {
- if (Math.abs(aspectRatio - ratio) < 0.01) {
- return label;
- }
- }
-
- // If not a common ratio, format as decimal
- return aspectRatio.toFixed(2);
- };
-
- // Check if current mode is "Original"
- const isOriginal = canvasMode === "original";
-
- // Get display name for current aspect ratio
- const getDisplayName = (): string => {
- // If explicitly set to original mode, always show "Original"
- if (canvasMode === "original") {
- return "Original";
- }
-
- if (currentPreset) {
- return currentPreset.name;
- }
-
- return formatAspectRatio(getCurrentAspectRatio());
- };
-
- return {
- currentPreset,
- canvasMode,
- isOriginal,
- getCurrentAspectRatio,
- getOriginalAspectRatio,
- formatAspectRatio,
- getDisplayName,
- canvasSize,
- canvasPresets,
- };
-}
+import { useEditorStore } from "@/stores/editor-store"; +import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function useAspectRatio() { + const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); + const { mediaItems } = useMediaStore(); + const { tracks } = useTimelineStore(); + + // Find the current preset based on canvas size + const currentPreset = canvasPresets.find( + (preset) => + preset.width === canvasSize.width && preset.height === canvasSize.height + ); + + // Get the original aspect ratio from the first video/image in timeline + const getOriginalAspectRatio = (): number => { + // Find first video or image in timeline + for (const track of tracks) { + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId + ); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } + } + } + } + return 16 / 9; // Default aspect ratio + }; + + // Get current aspect ratio + const getCurrentAspectRatio = (): number => { + return canvasSize.width / canvasSize.height; + }; + + // Format aspect ratio as a readable string + const formatAspectRatio = (aspectRatio: number): string => { + // Check if it matches a common aspect ratio + const ratios = [ + { ratio: 16 / 9, label: "16:9" }, + { ratio: 9 / 16, label: "9:16" }, + { ratio: 1, label: "1:1" }, + { ratio: 4 / 3, label: "4:3" }, + { ratio: 3 / 4, label: "3:4" }, + { ratio: 21 / 9, label: "21:9" }, + ]; + + for (const { ratio, label } of ratios) { + if (Math.abs(aspectRatio - ratio) < 0.01) { + return label; + } + } + + // If not a common ratio, format as decimal + return aspectRatio.toFixed(2); + }; + + // Check if current mode is "Original" + const isOriginal = canvasMode === "original"; + + // Get display name for current aspect ratio + const getDisplayName = (): string => { + // If explicitly set to original mode, always show "Original" + if (canvasMode === "original") { + return "Original"; + } + + if (currentPreset) { + return currentPreset.name; + } + + return formatAspectRatio(getCurrentAspectRatio()); + }; + + return { + currentPreset, + canvasMode, + isOriginal, + getCurrentAspectRatio, + getOriginalAspectRatio, + formatAspectRatio, + getDisplayName, + canvasSize, + canvasPresets, + }; +} diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index eaab89d8..0b1d4008 100644 --- a/apps/web/src/hooks/use-drag-drop.ts +++ b/apps/web/src/hooks/use-drag-drop.ts @@ -1,85 +1,85 @@ -import { useState, useRef } from "react";
-
-interface UseDragDropOptions {
- onDrop?: (files: FileList) => void;
-}
-
-// Helper function to check if drag contains files from external sources (not internal app drags)
-const containsFiles = (dataTransfer: DataTransfer): boolean => {
- // Check if this is an internal app drag (media item)
- if (dataTransfer.types.includes("application/x-media-item")) {
- return false;
- }
-
- // Only show overlay for external file drags
- return dataTransfer.types.includes("Files");
-};
-
-export function useDragDrop(options: UseDragDropOptions = {}) {
- const [isDragOver, setIsDragOver] = useState(false);
- const dragCounterRef = useRef(0);
-
- const handleDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle external file drags, not internal app element drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
-
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle file drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Only handle file drags
- if (!containsFiles(e.dataTransfer)) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Only handle file drops
- if (
- options.onDrop &&
- e.dataTransfer.files &&
- containsFiles(e.dataTransfer)
- ) {
- options.onDrop(e.dataTransfer.files);
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- return {
- isDragOver,
- dragProps,
- };
-}
+import { useState, useRef } from "react"; + +interface UseDragDropOptions { + onDrop?: (files: FileList) => void; +} + +// Helper function to check if drag contains files from external sources (not internal app drags) +const containsFiles = (dataTransfer: DataTransfer): boolean => { + // Check if this is an internal app drag (media item) + if (dataTransfer.types.includes("application/x-media-item")) { + return false; + } + + // Only show overlay for external file drags + return dataTransfer.types.includes("Files"); +}; + +export function useDragDrop(options: UseDragDropOptions = {}) { + const [isDragOver, setIsDragOver] = useState(false); + const dragCounterRef = useRef(0); + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle external file drags, not internal app element drags + if (!containsFiles(e.dataTransfer)) { + return; + } + + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle file drags + if (!containsFiles(e.dataTransfer)) { + return; + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle file drags + if (!containsFiles(e.dataTransfer)) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Only handle file drops + if ( + options.onDrop && + e.dataTransfer.files && + containsFiles(e.dataTransfer) + ) { + options.onDrop(e.dataTransfer.files); + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + return { + isDragOver, + dragProps, + }; +} diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 4e8f6aef..b2fad55f 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -28,52 +28,52 @@ export function useEditorActions() { // Playback actions useActionHandler("toggle-play", () => { toggle(); - }); + }, undefined); useActionHandler("stop-playback", () => { if (isPlaying) { toggle(); } seek(0); - }); + }, undefined); useActionHandler("seek-forward", (args) => { const seconds = args?.seconds ?? 1; seek(Math.min(duration, currentTime + seconds)); - }); + }, undefined); useActionHandler("seek-backward", (args) => { const seconds = args?.seconds ?? 1; seek(Math.max(0, currentTime - seconds)); - }); + }, undefined); useActionHandler("frame-step-forward", () => { const projectFps = activeProject?.fps || 30; seek(Math.min(duration, currentTime + 1 / projectFps)); - }); + }, undefined); useActionHandler("frame-step-backward", () => { const projectFps = activeProject?.fps || 30; seek(Math.max(0, currentTime - 1 / projectFps)); - }); + }, undefined); useActionHandler("jump-forward", (args) => { const seconds = args?.seconds ?? 5; seek(Math.min(duration, currentTime + seconds)); - }); + }, undefined); useActionHandler("jump-backward", (args) => { const seconds = args?.seconds ?? 5; seek(Math.max(0, currentTime - seconds)); - }); + }, undefined); useActionHandler("goto-start", () => { seek(0); - }); + }, undefined); useActionHandler("goto-end", () => { seek(duration); - }); + }, undefined); // Timeline editing actions useActionHandler("split-element", () => { @@ -98,7 +98,7 @@ export function useEditorActions() { toast.error("Playhead must be within selected element"); } } - }); + }, undefined); useActionHandler("delete-selected", () => { if (selectedElements.length === 0) { @@ -110,7 +110,7 @@ export function useEditorActions() { } ); clearSelectedElements(); - }); + }, undefined); useActionHandler("select-all", () => { const allElements = tracks.flatMap((track: any) => @@ -120,7 +120,7 @@ export function useEditorActions() { })) ); setSelectedElements(allElements); - }); + }, undefined); useActionHandler("duplicate-selected", () => { if (selectedElements.length !== 1) { @@ -144,18 +144,18 @@ export function useEditorActions() { startTime: newStartTime, }); } - }); + }, undefined); useActionHandler("toggle-snapping", () => { toggleSnapping(); - }); + }, undefined); // History actions useActionHandler("undo", () => { undo(); - }); + }, undefined); useActionHandler("redo", () => { redo(); - }); + }, undefined); } diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 7887f3f1..7224c1c8 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -25,7 +25,7 @@ export function useKeybindingsListener() { ev.preventDefault(); // Handle actions with default arguments - let actionArgs: any = undefined; + let actionArgs: any; if (boundAction === "seek-forward") { actionArgs = { seconds: 1 }; diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx index 2b0fe1df..48fab93c 100644 --- a/apps/web/src/hooks/use-mobile.tsx +++ b/apps/web/src/hooks/use-mobile.tsx @@ -1,19 +1,21 @@ -import * as React from "react" +import * as React from "react"; -const MOBILE_BREAKPOINT = 768 +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + const [isMobile, setIsMobile] = React.useState<boolean | undefined>( + undefined + ); React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); - return !!isMobile + return !!isMobile; } diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts index c40617c1..46e77fa9 100644 --- a/apps/web/src/hooks/use-selection-box.ts +++ b/apps/web/src/hooks/use-selection-box.ts @@ -1,199 +1,199 @@ -import { useState, useEffect, useCallback } from "react";
-
-interface UseSelectionBoxProps {
- containerRef: React.RefObject<HTMLElement>;
- playheadRef?: React.RefObject<HTMLElement>;
- onSelectionComplete: (
- elements: { trackId: string; elementId: string }[]
- ) => void;
- isEnabled?: boolean;
-}
-
-interface SelectionBoxState {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
-}
-
-export function useSelectionBox({
- containerRef,
- playheadRef,
- onSelectionComplete,
- isEnabled = true,
-}: UseSelectionBoxProps) {
- const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
- null
- );
- const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
-
- // Mouse down handler to start selection
- const handleMouseDown = useCallback(
- (e: React.MouseEvent) => {
- if (!isEnabled) return;
-
- // Only start selection on empty space clicks
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- return;
- }
- if (playheadRef?.current?.contains(e.target as Node)) {
- return;
- }
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- return;
- }
- // Don't start selection when clicking in the ruler area - this interferes with playhead dragging
- if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
- return;
- }
-
- setSelectionBox({
- startPos: { x: e.clientX, y: e.clientY },
- currentPos: { x: e.clientX, y: e.clientY },
- isActive: false, // Will become active when mouse moves
- });
- },
- [isEnabled, playheadRef]
- );
-
- // Function to select elements within the selection box
- const selectElementsInBox = useCallback(
- (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
- if (!containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate selection rectangle in container coordinates
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const endX = endPos.x - containerRect.left;
- const endY = endPos.y - containerRect.top;
-
- const selectionRect = {
- left: Math.min(startX, endX),
- top: Math.min(startY, endY),
- right: Math.max(startX, endX),
- bottom: Math.max(startY, endY),
- };
-
- // Find all timeline elements within the selection rectangle
- const timelineElements = container.querySelectorAll(".timeline-element");
-
- const selectedElements: { trackId: string; elementId: string }[] = [];
-
- timelineElements.forEach((element) => {
- const elementRect = element.getBoundingClientRect();
- // Use absolute coordinates for more accurate intersection detection
- const elementAbsolute = {
- left: elementRect.left,
- top: elementRect.top,
- right: elementRect.right,
- bottom: elementRect.bottom,
- };
-
- const selectionAbsolute = {
- left: startPos.x,
- top: startPos.y,
- right: endPos.x,
- bottom: endPos.y,
- };
-
- // Normalize selection rectangle (handle dragging in any direction)
- const normalizedSelection = {
- left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
- top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
- right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
- bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
- };
-
- const elementId = element.getAttribute("data-element-id");
- const trackId = element.getAttribute("data-track-id");
-
- // Check if element intersects with selection rectangle (any overlap)
- // Using absolute coordinates for more precise detection
- const intersects = !(
- elementAbsolute.right < normalizedSelection.left ||
- elementAbsolute.left > normalizedSelection.right ||
- elementAbsolute.bottom < normalizedSelection.top ||
- elementAbsolute.top > normalizedSelection.bottom
- );
-
- if (intersects && elementId && trackId) {
- selectedElements.push({ trackId, elementId });
- }
- });
-
- // Always call the callback - with elements or empty array to clear selection
- console.log(
- JSON.stringify({ selectElementsInBox: selectedElements.length })
- );
- onSelectionComplete(selectedElements);
- },
- [containerRef, onSelectionComplete]
- );
-
- // Effect to track selection box movement
- useEffect(() => {
- if (!selectionBox) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
- const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
-
- // Start selection if mouse moved more than 5px
- const shouldActivate = deltaX > 5 || deltaY > 5;
-
- const newSelectionBox = {
- ...selectionBox,
- currentPos: { x: e.clientX, y: e.clientY },
- isActive: shouldActivate || selectionBox.isActive,
- };
-
- setSelectionBox(newSelectionBox);
-
- // Real-time visual feedback: update selection as we drag
- if (newSelectionBox.isActive) {
- selectElementsInBox(
- newSelectionBox.startPos,
- newSelectionBox.currentPos
- );
- }
- };
-
- const handleMouseUp = () => {
- console.log(
- JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
- );
-
- // If we had an active selection, mark that we just finished selecting
- if (selectionBox?.isActive) {
- console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
- setJustFinishedSelecting(true);
- // Clear the flag after a short delay to allow click events to check it
- setTimeout(() => {
- console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
- setJustFinishedSelecting(false);
- }, 50);
- }
-
- // Don't call selectElementsInBox again - real-time selection already handled it
- // Just clean up the selection box visual
- setSelectionBox(null);
- };
-
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [selectionBox, selectElementsInBox]);
-
- return {
- selectionBox,
- handleMouseDown,
- isSelecting: selectionBox?.isActive || false,
- justFinishedSelecting,
- };
-}
+import { useState, useEffect, useCallback } from "react"; + +interface UseSelectionBoxProps { + containerRef: React.RefObject<HTMLElement>; + playheadRef?: React.RefObject<HTMLElement>; + onSelectionComplete: ( + elements: { trackId: string; elementId: string }[] + ) => void; + isEnabled?: boolean; +} + +interface SelectionBoxState { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; +} + +export function useSelectionBox({ + containerRef, + playheadRef, + onSelectionComplete, + isEnabled = true, +}: UseSelectionBoxProps) { + const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>( + null + ); + const [justFinishedSelecting, setJustFinishedSelecting] = useState(false); + + // Mouse down handler to start selection + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!isEnabled) return; + + // Only start selection on empty space clicks + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } + if (playheadRef?.current?.contains(e.target as Node)) { + return; + } + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + return; + } + // Don't start selection when clicking in the ruler area - this interferes with playhead dragging + if ((e.target as HTMLElement).closest("[data-ruler-area]")) { + return; + } + + setSelectionBox({ + startPos: { x: e.clientX, y: e.clientY }, + currentPos: { x: e.clientX, y: e.clientY }, + isActive: false, // Will become active when mouse moves + }); + }, + [isEnabled, playheadRef] + ); + + // Function to select elements within the selection box + const selectElementsInBox = useCallback( + (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => { + if (!containerRef.current) return; + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + // Calculate selection rectangle in container coordinates + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const endX = endPos.x - containerRect.left; + const endY = endPos.y - containerRect.top; + + const selectionRect = { + left: Math.min(startX, endX), + top: Math.min(startY, endY), + right: Math.max(startX, endX), + bottom: Math.max(startY, endY), + }; + + // Find all timeline elements within the selection rectangle + const timelineElements = container.querySelectorAll(".timeline-element"); + + const selectedElements: { trackId: string; elementId: string }[] = []; + + timelineElements.forEach((element) => { + const elementRect = element.getBoundingClientRect(); + // Use absolute coordinates for more accurate intersection detection + const elementAbsolute = { + left: elementRect.left, + top: elementRect.top, + right: elementRect.right, + bottom: elementRect.bottom, + }; + + const selectionAbsolute = { + left: startPos.x, + top: startPos.y, + right: endPos.x, + bottom: endPos.y, + }; + + // Normalize selection rectangle (handle dragging in any direction) + const normalizedSelection = { + left: Math.min(selectionAbsolute.left, selectionAbsolute.right), + top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom), + right: Math.max(selectionAbsolute.left, selectionAbsolute.right), + bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom), + }; + + const elementId = element.getAttribute("data-element-id"); + const trackId = element.getAttribute("data-track-id"); + + // Check if element intersects with selection rectangle (any overlap) + // Using absolute coordinates for more precise detection + const intersects = !( + elementAbsolute.right < normalizedSelection.left || + elementAbsolute.left > normalizedSelection.right || + elementAbsolute.bottom < normalizedSelection.top || + elementAbsolute.top > normalizedSelection.bottom + ); + + if (intersects && elementId && trackId) { + selectedElements.push({ trackId, elementId }); + } + }); + + // Always call the callback - with elements or empty array to clear selection + console.log( + JSON.stringify({ selectElementsInBox: selectedElements.length }) + ); + onSelectionComplete(selectedElements); + }, + [containerRef, onSelectionComplete] + ); + + // Effect to track selection box movement + useEffect(() => { + if (!selectionBox) return; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = Math.abs(e.clientX - selectionBox.startPos.x); + const deltaY = Math.abs(e.clientY - selectionBox.startPos.y); + + // Start selection if mouse moved more than 5px + const shouldActivate = deltaX > 5 || deltaY > 5; + + const newSelectionBox = { + ...selectionBox, + currentPos: { x: e.clientX, y: e.clientY }, + isActive: shouldActivate || selectionBox.isActive, + }; + + setSelectionBox(newSelectionBox); + + // Real-time visual feedback: update selection as we drag + if (newSelectionBox.isActive) { + selectElementsInBox( + newSelectionBox.startPos, + newSelectionBox.currentPos + ); + } + }; + + const handleMouseUp = () => { + console.log( + JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } }) + ); + + // If we had an active selection, mark that we just finished selecting + if (selectionBox?.isActive) { + console.log(JSON.stringify({ settingJustFinishedSelecting: true })); + setJustFinishedSelecting(true); + // Clear the flag after a short delay to allow click events to check it + setTimeout(() => { + console.log(JSON.stringify({ clearingJustFinishedSelecting: true })); + setJustFinishedSelecting(false); + }, 50); + } + + // Don't call selectElementsInBox again - real-time selection already handled it + // Just clean up the selection box visual + setSelectionBox(null); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [selectionBox, selectElementsInBox]); + + return { + selectionBox, + handleMouseDown, + isSelecting: selectionBox?.isActive || false, + justFinishedSelecting, + }; +} diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 9a82c23f..008f61a4 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,157 +1,157 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import { useState, useEffect, useCallback } from "react";
-
-interface UseTimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
-}
-
-export function useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: UseTimelinePlayheadProps) {
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- // Ruler drag detection state
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation(); // Prevent ruler drag from triggering
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- // Ruler mouse down handler
- const handleRulerMouseDown = useCallback(
- (e: React.MouseEvent) => {
- // Only handle left mouse button
- if (e.button !== 0) return;
-
- // Don't interfere if clicking on the playhead itself
- if (playheadRef?.current?.contains(e.target as Node)) return;
-
- e.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
-
- // Start scrubbing immediately
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rect = ruler.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
- // Use frame snapping for playhead scrubbing
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
- setScrubTime(time);
- seek(time); // update video preview in real time
- },
- [duration, zoomLevel, seek, rulerRef]
- );
-
- // Mouse move/up event handlers
- useEffect(() => {
- if (!isScrubbing) return;
- const onMouseMove = (e: MouseEvent) => {
- handleScrub(e);
- // Mark that we've dragged if ruler drag is active
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
- }
- };
- const onMouseUp = (e: MouseEvent) => {
- setIsScrubbing(false);
- if (scrubTime !== null) seek(scrubTime); // finalize seek
- setScrubTime(null);
-
- // Handle ruler click vs drag
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- // If we didn't drag, treat it as a click-to-seek
- if (!hasDraggedRuler) {
- handleScrub(e);
- }
- setHasDraggedRuler(false);
- }
- };
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- ]);
-
- // --- Playhead auto-scroll effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMin = 0;
- const scrollMax = rulerViewport.scrollWidth - viewportWidth;
- // Center the playhead if it's not visible (100px buffer)
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- if (
- playheadPx < rulerViewport.scrollLeft + 100 ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
- ) {
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
-
- return {
- playheadPosition,
- handlePlayheadMouseDown,
- handleRulerMouseDown,
- isDraggingRuler,
- };
-}
+import { snapTimeToFrame } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useState, useEffect, useCallback } from "react"; + +interface UseTimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; +} + +export function useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState<number | null>(null); + + // Ruler drag detection state + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); // Prevent ruler drag from triggering + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + // Ruler mouse down handler + const handleRulerMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only handle left mouse button + if (e.button !== 0) return; + + // Don't interfere if clicking on the playhead itself + if (playheadRef?.current?.contains(e.target as Node)) return; + + e.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + // Start scrubbing immediately + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rect = ruler.getBoundingClientRect(); + const x = e.clientX - rect.left; + const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + // Use frame snapping for playhead scrubbing + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek, rulerRef] + ); + + // Mouse move/up event handlers + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => { + handleScrub(e); + // Mark that we've dragged if ruler drag is active + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + + // Handle ruler click vs drag + if (isDraggingRuler) { + setIsDraggingRuler(false); + // If we didn't drag, treat it as a click-to-seek + if (!hasDraggedRuler) { + handleScrub(e); + } + setHasDraggedRuler(false); + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + ]); + + // --- Playhead auto-scroll effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerViewport || !tracksViewport) return; + const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 + const viewportWidth = rulerViewport.clientWidth; + const scrollMin = 0; + const scrollMax = rulerViewport.scrollWidth - viewportWidth; + // Center the playhead if it's not visible (100px buffer) + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2) + ); + if ( + playheadPx < rulerViewport.scrollLeft + 100 || + playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 + ) { + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); + + return { + playheadPosition, + handlePlayheadMouseDown, + handleRulerMouseDown, + isDraggingRuler, + }; +} diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index 4f5e0af0..e764c550 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,60 +1,60 @@ -import { useState, useCallback, useEffect, RefObject } from "react";
-
-interface UseTimelineZoomProps {
- containerRef: RefObject<HTMLDivElement>;
- isInTimeline?: boolean;
-}
-
-interface UseTimelineZoomReturn {
- zoomLevel: number;
- setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
- handleWheel: (e: React.WheelEvent) => void;
-}
-
-export function useTimelineZoom({
- containerRef,
- isInTimeline = false,
-}: UseTimelineZoomProps): UseTimelineZoomReturn {
- const [zoomLevel, setZoomLevel] = useState(1);
-
- const handleWheel = useCallback((e: React.WheelEvent) => {
- // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- 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
- }, []);
-
- // Prevent browser zooming in/out when in timeline
- useEffect(() => {
- const preventZoom = (e: WheelEvent) => {
- if (
- isInTimeline &&
- (e.ctrlKey || e.metaKey) &&
- containerRef.current?.contains(e.target as Node)
- ) {
- e.preventDefault();
- }
- };
-
- document.addEventListener("wheel", preventZoom, { passive: false });
-
- return () => {
- document.removeEventListener("wheel", preventZoom);
- };
- }, [isInTimeline, containerRef]);
-
- return {
- zoomLevel,
- setZoomLevel,
- handleWheel,
- };
-}
+import { useState, useCallback, useEffect, RefObject } from "react"; + +interface UseTimelineZoomProps { + containerRef: RefObject<HTMLDivElement>; + isInTimeline?: boolean; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (e: React.WheelEvent) => void; +} + +export function useTimelineZoom({ + containerRef, + isInTimeline = false, +}: UseTimelineZoomProps): UseTimelineZoomReturn { + const [zoomLevel, setZoomLevel] = useState(1); + + const handleWheel = useCallback((e: React.WheelEvent) => { + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + 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 + }, []); + + // Prevent browser zooming in/out when in timeline + useEffect(() => { + const preventZoom = (e: WheelEvent) => { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + containerRef.current?.contains(e.target as Node) + ) { + e.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { passive: false }); + + return () => { + document.removeEventListener("wheel", preventZoom); + }; + }, [isInTimeline, containerRef]); + + return { + zoomLevel, + setZoomLevel, + handleWheel, + }; +} diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index c682cd1f..dcc02dcd 100644 --- a/apps/web/src/hooks/use-toast.ts +++ b/apps/web/src/hooks/use-toast.ts @@ -6,7 +6,7 @@ import * as React from "react"; import type { ToastActionElement, ToastProps } from "../components/ui/toast"; const TOAST_LIMIT = 1; -const TOAST_REMOVE_DELAY = 1000000; +const TOAST_REMOVE_DELAY = 1_000_000; type ToasterToast = ToastProps & { id: string; @@ -64,7 +64,7 @@ const addToRemoveQueue = (toastId: string) => { toastTimeouts.delete(toastId); dispatch({ type: "REMOVE_TOAST", - toastId: toastId, + toastId, }); }, TOAST_REMOVE_DELAY); @@ -162,7 +162,7 @@ function toast({ ...props }: Toast) { }); return { - id: id, + id, dismiss, update, }; diff --git a/apps/web/src/lib/blog-query.ts b/apps/web/src/lib/blog-query.ts index 430f58bf..5cba0cf3 100644 --- a/apps/web/src/lib/blog-query.ts +++ b/apps/web/src/lib/blog-query.ts @@ -1,4 +1,10 @@ -import type { MarbleAuthorList, MarbleCategoryList, MarblePost, MarblePostList, MarbleTagList } from '@/types/post'; +import type { + MarbleAuthorList, + MarbleCategoryList, + MarblePost, + MarblePostList, + MarbleTagList, +} from "@/types/post"; import { unified } from "unified"; import rehypeParse from "rehype-parse"; import rehypeStringify from "rehype-stringify"; @@ -6,50 +12,53 @@ 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 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; + 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 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 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); + const file = await processor.process(html); + return String(file); } diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts index c4444a62..0372f25b 100644 --- a/apps/web/src/lib/fetch-github-stars.ts +++ b/apps/web/src/lib/fetch-github-stars.ts @@ -19,8 +19,8 @@ export async function getStars(): Promise<string> { if (count >= 1_000_000) return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; - if (count >= 1_000) - return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; + if (count >= 1000) + return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k"; return count.toString(); } catch (error) { console.error("Failed to fetch GitHub stars:", error); diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts index ae0c267b..c34200f6 100644 --- a/apps/web/src/lib/ffmpeg-utils.ts +++ b/apps/web/src/lib/ffmpeg-utils.ts @@ -1,5 +1,5 @@ -import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { toBlobURL } from '@ffmpeg/util'; +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { toBlobURL } from "@ffmpeg/util"; let ffmpeg: FFmpeg | null = null; @@ -7,13 +7,13 @@ export const initFFmpeg = async (): Promise<FFmpeg> => { if (ffmpeg) return ffmpeg; ffmpeg = new FFmpeg(); - + // Use locally hosted files instead of CDN - const baseURL = '/ffmpeg'; - + const baseURL = "/ffmpeg"; + await ffmpeg.load({ - coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), - wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), }); return ffmpeg; @@ -21,34 +21,42 @@ export const initFFmpeg = async (): Promise<FFmpeg> => { export const generateThumbnail = async ( videoFile: File, - timeInSeconds: number = 1 + timeInSeconds = 1 ): Promise<string> => { const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'thumbnail.jpg'; - + + const inputName = "input.mp4"; + const outputName = "thumbnail.jpg"; + // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()) + ); + // Generate thumbnail at specific time await ffmpeg.exec([ - '-i', inputName, - '-ss', timeInSeconds.toString(), - '-vframes', '1', - '-vf', 'scale=320:240', - '-q:v', '2', - outputName + "-i", + inputName, + "-ss", + timeInSeconds.toString(), + "-vframes", + "1", + "-vf", + "scale=320:240", + "-q:v", + "2", + outputName, ]); - + // Read output file const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'image/jpeg' }); - + const blob = new Blob([data], { type: "image/jpeg" }); + // Cleanup await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); - + return URL.createObjectURL(blob); }; @@ -59,43 +67,52 @@ export const trimVideo = async ( onProgress?: (progress: number) => void ): Promise<Blob> => { const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'output.mp4'; - + + const inputName = "input.mp4"; + const outputName = "output.mp4"; + // Set up progress callback if (onProgress) { - ffmpeg.on('progress', ({ progress }) => { + ffmpeg.on("progress", ({ progress }) => { onProgress(progress * 100); }); } - + // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()) + ); + const duration = endTime - startTime; - + // Trim video await ffmpeg.exec([ - '-i', inputName, - '-ss', startTime.toString(), - '-t', duration.toString(), - '-c', 'copy', // Use stream copy for faster processing - outputName + "-i", + inputName, + "-ss", + startTime.toString(), + "-t", + duration.toString(), + "-c", + "copy", // Use stream copy for faster processing + outputName, ]); - + // Read output file const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'video/mp4' }); - + const blob = new Blob([data], { type: "video/mp4" }); + // Cleanup await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); - + return blob; }; -export const getVideoInfo = async (videoFile: File): Promise<{ +export const getVideoInfo = async ( + videoFile: File +): Promise<{ duration: number; width: number; height: number; @@ -103,27 +120,32 @@ export const getVideoInfo = async (videoFile: File): Promise<{ }> => { const ffmpeg = await initFFmpeg(); - const inputName = 'input.mp4'; + const inputName = "input.mp4"; // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()) + ); // Capture FFmpeg stderr output with a one-time listener pattern - let ffmpegOutput = ''; + let ffmpegOutput = ""; let listening = true; const listener = (data: string) => { if (listening) ffmpegOutput += data; }; - ffmpeg.on('log', ({ message }) => listener(message)); + ffmpeg.on("log", ({ message }) => listener(message)); // Run ffmpeg to get info (stderr will contain the info) try { - await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']); + await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]); } catch (error) { listening = false; await ffmpeg.deleteFile(inputName); - console.error('FFmpeg execution failed:', error); - throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.'); + console.error("FFmpeg execution failed:", error); + throw new Error( + "Failed to extract video info. The file may be corrupted or in an unsupported format." + ); } // Disable listener after exec completes @@ -143,8 +165,12 @@ export const getVideoInfo = async (videoFile: File): Promise<{ duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); } - const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/); - let width = 0, height = 0, fps = 0; + const videoStreamMatch = ffmpegOutput.match( + /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/ + ); + let width = 0, + height = 0, + fps = 0; if (videoStreamMatch) { width = parseInt(videoStreamMatch[1]); height = parseInt(videoStreamMatch[2]); @@ -155,7 +181,7 @@ export const getVideoInfo = async (videoFile: File): Promise<{ duration, width, height, - fps + fps, }; }; @@ -164,68 +190,81 @@ export const convertToWebM = async ( onProgress?: (progress: number) => void ): Promise<Blob> => { const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; - const outputName = 'output.webm'; - + + const inputName = "input.mp4"; + const outputName = "output.webm"; + // Set up progress callback if (onProgress) { - ffmpeg.on('progress', ({ progress }) => { + ffmpeg.on("progress", ({ progress }) => { onProgress(progress * 100); }); } - + // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()) + ); + // Convert to WebM await ffmpeg.exec([ - '-i', inputName, - '-c:v', 'libvpx-vp9', - '-crf', '30', - '-b:v', '0', - '-c:a', 'libopus', - outputName + "-i", + inputName, + "-c:v", + "libvpx-vp9", + "-crf", + "30", + "-b:v", + "0", + "-c:a", + "libopus", + outputName, ]); - + // Read output file const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: 'video/webm' }); - + const blob = new Blob([data], { type: "video/webm" }); + // Cleanup await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); - + return blob; }; export const extractAudio = async ( videoFile: File, - format: 'mp3' | 'wav' = 'mp3' + format: "mp3" | "wav" = "mp3" ): Promise<Blob> => { const ffmpeg = await initFFmpeg(); - - const inputName = 'input.mp4'; + + const inputName = "input.mp4"; const outputName = `output.${format}`; - + // Write input file - await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - + await ffmpeg.writeFile( + inputName, + new Uint8Array(await videoFile.arrayBuffer()) + ); + // Extract audio await ffmpeg.exec([ - '-i', inputName, - '-vn', // Disable video - '-acodec', format === 'mp3' ? 'libmp3lame' : 'pcm_s16le', - outputName + "-i", + inputName, + "-vn", // Disable video + "-acodec", + format === "mp3" ? "libmp3lame" : "pcm_s16le", + outputName, ]); - + // Read output file const data = await ffmpeg.readFile(outputName); const blob = new Blob([data], { type: `audio/${format}` }); - + // Cleanup await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); - + return blob; -};
\ No newline at end of file +}; diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts index 0389054a..b47b3c3e 100644 --- a/apps/web/src/lib/font-config.ts +++ b/apps/web/src/lib/font-config.ts @@ -1,39 +1,39 @@ -import {
- Inter,
- Roboto,
- Open_Sans,
- Playfair_Display,
- Comic_Neue,
-} from "next/font/google";
-
-// Configure all fonts
-const inter = Inter({ subsets: ["latin"] });
-const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
-const openSans = Open_Sans({ subsets: ["latin"] });
-const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
-const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
-
-// Export font class mapping for use in components
-export const FONT_CLASS_MAP = {
- Inter: inter.className,
- Roboto: roboto.className,
- "Open Sans": openSans.className,
- "Playfair Display": playfairDisplay.className,
- "Comic Neue": comicNeue.className,
- Arial: "",
- Helvetica: "",
- "Times New Roman": "",
- Georgia: "",
-} as const;
-
-// Export individual fonts for use in layout
-export const fonts = {
- inter,
- roboto,
- openSans,
- playfairDisplay,
- comicNeue,
-};
-
-// Default font for the body
-export const defaultFont = inter;
+import { + Inter, + Roboto, + Open_Sans, + Playfair_Display, + Comic_Neue, +} from "next/font/google"; + +// Configure all fonts +const inter = Inter({ subsets: ["latin"] }); +const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] }); +const openSans = Open_Sans({ subsets: ["latin"] }); +const playfairDisplay = Playfair_Display({ subsets: ["latin"] }); +const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] }); + +// Export font class mapping for use in components +export const FONT_CLASS_MAP = { + Inter: inter.className, + Roboto: roboto.className, + "Open Sans": openSans.className, + "Playfair Display": playfairDisplay.className, + "Comic Neue": comicNeue.className, + Arial: "", + Helvetica: "", + "Times New Roman": "", + Georgia: "", +} as const; + +// Export individual fonts for use in layout +export const fonts = { + inter, + roboto, + openSans, + playfairDisplay, + comicNeue, +}; + +// Default font for the body +export const defaultFont = inter; diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index ac63a64c..e1996cf5 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -1,16 +1,16 @@ -// lib/rate-limit.ts
-import { Ratelimit } from "@upstash/ratelimit";
-import { Redis } from "@upstash/redis";
-import { env } from "@/env";
-
-const redis = new Redis({
- url: env.UPSTASH_REDIS_REST_URL,
- token: env.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const waitlistRateLimit = new Ratelimit({
- redis,
- limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
- analytics: true,
- prefix: "waitlist-rate-limit",
-});
+// lib/rate-limit.ts +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, +}); + +export const waitlistRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute + analytics: true, + prefix: "waitlist-rate-limit", +}); diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts index f43a8798..597305c7 100644 --- a/apps/web/src/lib/storage/indexeddb-adapter.ts +++ b/apps/web/src/lib/storage/indexeddb-adapter.ts @@ -1,89 +1,89 @@ -import { StorageAdapter } from "./types";
-
-export class IndexedDBAdapter<T> implements StorageAdapter<T> {
- private dbName: string;
- private storeName: string;
- private version: number;
-
- constructor(dbName: string, storeName: string, version: number = 1) {
- this.dbName = dbName;
- this.storeName = storeName;
- this.version = version;
- }
-
- private async getDB(): Promise<IDBDatabase> {
- return new Promise((resolve, reject) => {
- const request = indexedDB.open(this.dbName, this.version);
-
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result);
-
- request.onupgradeneeded = (event) => {
- const db = (event.target as IDBOpenDBRequest).result;
- if (!db.objectStoreNames.contains(this.storeName)) {
- db.createObjectStore(this.storeName, { keyPath: "id" });
- }
- };
- });
- }
-
- async get(key: string): Promise<T | null> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readonly");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.get(key);
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result || null);
- });
- }
-
- async set(key: string, value: T): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.put({ id: key, ...value });
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-
- async remove(key: string): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.delete(key);
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-
- async list(): Promise<string[]> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readonly");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.getAllKeys();
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result as string[]);
- });
- }
-
- async clear(): Promise<void> {
- const db = await this.getDB();
- const transaction = db.transaction([this.storeName], "readwrite");
- const store = transaction.objectStore(this.storeName);
-
- return new Promise((resolve, reject) => {
- const request = store.clear();
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve();
- });
- }
-}
+import { StorageAdapter } from "./types"; + +export class IndexedDBAdapter<T> implements StorageAdapter<T> { + private dbName: string; + private storeName: string; + private version: number; + + constructor(dbName: string, storeName: string, version = 1) { + this.dbName = dbName; + this.storeName = storeName; + this.version = version; + } + + private async getDB(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: "id" }); + } + }; + }); + } + + async get(key: string): Promise<T | null> { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.get(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || null); + }); + } + + async set(key: string, value: T): Promise<void> { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.put({ id: key, ...value }); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async remove(key: string): Promise<void> { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.delete(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async list(): Promise<string[]> { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.getAllKeys(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as string[]); + }); + } + + async clear(): Promise<void> { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.clear(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } +} diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts index c01954e5..87b5c886 100644 --- a/apps/web/src/lib/storage/opfs-adapter.ts +++ b/apps/web/src/lib/storage/opfs-adapter.ts @@ -3,7 +3,7 @@ import { StorageAdapter } from "./types"; export class OPFSAdapter implements StorageAdapter<File> { private directoryName: string; - constructor(directoryName: string = "media") { + constructor(directoryName = "media") { this.directoryName = directoryName; } diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index b3147946..df02d837 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -1,25 +1,25 @@ -// Time-related utility functions
-
-// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
-export const formatTimeCode = (
- timeInSeconds: number,
- format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
- fps: number = 30
-): string => {
- const hours = Math.floor(timeInSeconds / 3600);
- const minutes = Math.floor((timeInSeconds % 3600) / 60);
- const seconds = Math.floor(timeInSeconds % 60);
- const centiseconds = Math.floor((timeInSeconds % 1) * 100);
- const frames = Math.floor((timeInSeconds % 1) * fps);
-
- switch (format) {
- case "MM:SS":
- return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS:CS":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
- case "HH:MM:SS:FF":
- return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
- }
-};
+// Time-related utility functions + +// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF) +export const formatTimeCode = ( + timeInSeconds: number, + format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS", + fps = 30 +): string => { + const hours = Math.floor(timeInSeconds / 3600); + const minutes = Math.floor((timeInSeconds % 3600) / 60); + const seconds = Math.floor(timeInSeconds % 60); + const centiseconds = Math.floor((timeInSeconds % 1) * 100); + const frames = Math.floor((timeInSeconds % 1) * fps); + + switch (format) { + case "MM:SS": + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + case "HH:MM:SS": + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + case "HH:MM:SS:CS": + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`; + case "HH:MM:SS:FF": + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`; + } +}; diff --git a/apps/web/src/lib/timeline.ts b/apps/web/src/lib/timeline.ts index 531defac..85fcabe6 100644 --- a/apps/web/src/lib/timeline.ts +++ b/apps/web/src/lib/timeline.ts @@ -1,54 +1,54 @@ -import { TimelineElement } from "@/types/timeline";
-
-// Helper function to check for element overlaps and prevent invalid timeline states
-export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
-
- for (let i = 0; i < sortedElements.length - 1; i++) {
- const current = sortedElements[i];
- const next = sortedElements[i + 1];
-
- const currentEnd =
- current.startTime +
- (current.duration - current.trimStart - current.trimEnd);
-
- // Check if current element overlaps with next element
- if (currentEnd > next.startTime) return true; // Overlap detected
- }
-
- return false; // No overlaps
-};
-
-// Helper function to resolve overlaps by adjusting element positions
-export const resolveElementOverlaps = (
- elements: TimelineElement[]
-): TimelineElement[] => {
- // Sort elements by start time
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime
- );
- const resolvedElements: TimelineElement[] = [];
-
- for (let i = 0; i < sortedElements.length; i++) {
- const current = { ...sortedElements[i] };
-
- if (resolvedElements.length > 0) {
- const previous = resolvedElements[resolvedElements.length - 1];
- const previousEnd =
- previous.startTime +
- (previous.duration - previous.trimStart - previous.trimEnd);
-
- // If current element would overlap with previous, push it after previous ends
- if (current.startTime < previousEnd) {
- current.startTime = previousEnd;
- }
- }
-
- resolvedElements.push(current);
- }
-
- return resolvedElements;
-};
+import { TimelineElement } from "@/types/timeline"; + +// Helper function to check for element overlaps and prevent invalid timeline states +export const checkElementOverlaps = (elements: TimelineElement[]): boolean => { + // Sort elements by start time + const sortedElements = [...elements].sort( + (a, b) => a.startTime - b.startTime + ); + + for (let i = 0; i < sortedElements.length - 1; i++) { + const current = sortedElements[i]; + const next = sortedElements[i + 1]; + + const currentEnd = + current.startTime + + (current.duration - current.trimStart - current.trimEnd); + + // Check if current element overlaps with next element + if (currentEnd > next.startTime) return true; // Overlap detected + } + + return false; // No overlaps +}; + +// Helper function to resolve overlaps by adjusting element positions +export const resolveElementOverlaps = ( + elements: TimelineElement[] +): TimelineElement[] => { + // Sort elements by start time + const sortedElements = [...elements].sort( + (a, b) => a.startTime - b.startTime + ); + const resolvedElements: TimelineElement[] = []; + + for (let i = 0; i < sortedElements.length; i++) { + const current = { ...sortedElements[i] }; + + if (resolvedElements.length > 0) { + const previous = resolvedElements[resolvedElements.length - 1]; + const previousEnd = + previous.startTime + + (previous.duration - previous.trimStart - previous.trimEnd); + + // If current element would overlap with previous, push it after previous ends + if (current.startTime < previousEnd) { + current.startTime = previousEnd; + } + } + + resolvedElements.push(current); + } + + return resolvedElements; +}; diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts index 22b15b78..43a620b0 100644 --- a/apps/web/src/lib/waitlist.ts +++ b/apps/web/src/lib/waitlist.ts @@ -1,13 +1,13 @@ -import { db, sql, waitlist } from "@opencut/db";
-
-export async function getWaitlistCount() {
- try {
- const result = await db
- .select({ count: sql<number>`count(*)` })
- .from(waitlist);
- return result[0]?.count || 0;
- } catch (error) {
- console.error("Failed to fetch waitlist count:", error);
- return 0;
- }
-}
+import { db, sql, waitlist } from "@opencut/db"; + +export async function getWaitlistCount() { + try { + const result = await db + .select({ count: sql<number>`count(*)` }) + .from(waitlist); + return result[0]?.count || 0; + } catch (error) { + console.error("Failed to fetch waitlist count:", error); + return 0; + } +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index e2562ef1..b5fc2b4b 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,24 +1,24 @@ -import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-
-export async function middleware(request: NextRequest) {
- // Handle fuckcapcut.com domain redirect
- if (request.headers.get("host") === "fuckcapcut.com") {
- return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
- }
-
- return NextResponse.next();
-}
-
-export const config = {
- matcher: [
- /*
- * Match all request paths except for the ones starting with:
- * - api (API routes)
- * - _next/static (static files)
- * - _next/image (image optimization files)
- * - favicon.ico (favicon file)
- */
- "/((?!api|_next/static|_next/image|favicon.ico).*)",
- ],
-};
+import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + // Handle fuckcapcut.com domain redirect + if (request.headers.get("host") === "fuckcapcut.com") { + return NextResponse.redirect("https://opencut.app/why-not-capcut", 301); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + "/((?!api|_next/static|_next/image|favicon.ico).*)", + ], +}; diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index c24929a2..aac7077e 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,105 +1,104 @@ -import { create } from "zustand";
-import { CanvasSize, CanvasPreset } from "@/types/editor";
-
-type CanvasMode = "preset" | "original" | "custom";
-
-interface EditorState {
- // Loading states
- isInitializing: boolean;
- isPanelsReady: boolean;
-
- // Canvas/Project settings
- canvasSize: CanvasSize;
- canvasMode: CanvasMode;
- canvasPresets: CanvasPreset[];
-
- // Actions
- setInitializing: (loading: boolean) => void;
- setPanelsReady: (ready: boolean) => void;
- initializeApp: () => Promise<void>;
- setCanvasSize: (size: CanvasSize) => void;
- setCanvasSizeToOriginal: (aspectRatio: number) => void;
- setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
-}
-
-const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
- { name: "16:9", width: 1920, height: 1080 },
- { name: "9:16", width: 1080, height: 1920 },
- { name: "1:1", width: 1080, height: 1080 },
- { name: "4:3", width: 1440, height: 1080 },
-];
-
-// Helper function to find the best matching canvas preset for an aspect ratio
-const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
- // Calculate aspect ratio for each preset and find the closest match
- let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
- let smallestDifference = Math.abs(
- aspectRatio - bestMatch.width / bestMatch.height
- );
-
- for (const preset of DEFAULT_CANVAS_PRESETS) {
- const presetAspectRatio = preset.width / preset.height;
- const difference = Math.abs(aspectRatio - presetAspectRatio);
-
- if (difference < smallestDifference) {
- smallestDifference = difference;
- bestMatch = preset;
- }
- }
-
- // If the difference is still significant (> 0.1), create a custom size
- // based on the media aspect ratio with a reasonable resolution
- const bestAspectRatio = bestMatch.width / bestMatch.height;
- if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
- // Create custom dimensions based on the aspect ratio
- if (aspectRatio > 1) {
- // Landscape - use 1920 width
- return { width: 1920, height: Math.round(1920 / aspectRatio) };
- } else {
- // Portrait or square - use 1080 height
- return { width: Math.round(1080 * aspectRatio), height: 1080 };
- }
- }
-
- return { width: bestMatch.width, height: bestMatch.height };
-};
-
-export const useEditorStore = create<EditorState>((set, get) => ({
- // Initial states
- isInitializing: true,
- isPanelsReady: false,
- canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
- canvasMode: "preset" as CanvasMode,
- canvasPresets: DEFAULT_CANVAS_PRESETS,
-
- // Actions
- setInitializing: (loading) => {
- set({ isInitializing: loading });
- },
-
- setPanelsReady: (ready) => {
- set({ isPanelsReady: ready });
- },
-
- initializeApp: async () => {
- console.log("Initializing video editor...");
- set({ isInitializing: true, isPanelsReady: false });
-
- set({ isPanelsReady: true, isInitializing: false });
- console.log("Video editor ready");
- },
-
- setCanvasSize: (size) => {
- set({ canvasSize: size, canvasMode: "preset" });
- },
-
- setCanvasSizeToOriginal: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "original" });
- },
-
- setCanvasSizeFromAspectRatio: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "custom" });
- },
-}));
+import { create } from "zustand"; +import { CanvasSize, CanvasPreset } from "@/types/editor"; + +type CanvasMode = "preset" | "original" | "custom"; + +interface EditorState { + // Loading states + isInitializing: boolean; + isPanelsReady: boolean; + + // Canvas/Project settings + canvasSize: CanvasSize; + canvasMode: CanvasMode; + canvasPresets: CanvasPreset[]; + + // Actions + setInitializing: (loading: boolean) => void; + setPanelsReady: (ready: boolean) => void; + initializeApp: () => Promise<void>; + setCanvasSize: (size: CanvasSize) => void; + setCanvasSizeToOriginal: (aspectRatio: number) => void; + setCanvasSizeFromAspectRatio: (aspectRatio: number) => void; +} + +const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [ + { name: "16:9", width: 1920, height: 1080 }, + { name: "9:16", width: 1080, height: 1920 }, + { name: "1:1", width: 1080, height: 1080 }, + { name: "4:3", width: 1440, height: 1080 }, +]; + +// Helper function to find the best matching canvas preset for an aspect ratio +const findBestCanvasPreset = (aspectRatio: number): CanvasSize => { + // Calculate aspect ratio for each preset and find the closest match + let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD + let smallestDifference = Math.abs( + aspectRatio - bestMatch.width / bestMatch.height + ); + + for (const preset of DEFAULT_CANVAS_PRESETS) { + const presetAspectRatio = preset.width / preset.height; + const difference = Math.abs(aspectRatio - presetAspectRatio); + + if (difference < smallestDifference) { + smallestDifference = difference; + bestMatch = preset; + } + } + + // If the difference is still significant (> 0.1), create a custom size + // based on the media aspect ratio with a reasonable resolution + const bestAspectRatio = bestMatch.width / bestMatch.height; + if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) { + // Create custom dimensions based on the aspect ratio + if (aspectRatio > 1) { + // Landscape - use 1920 width + return { width: 1920, height: Math.round(1920 / aspectRatio) }; + } + // Portrait or square - use 1080 height + return { width: Math.round(1080 * aspectRatio), height: 1080 }; + } + + return { width: bestMatch.width, height: bestMatch.height }; +}; + +export const useEditorStore = create<EditorState>((set, get) => ({ + // Initial states + isInitializing: true, + isPanelsReady: false, + canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD + canvasMode: "preset" as CanvasMode, + canvasPresets: DEFAULT_CANVAS_PRESETS, + + // Actions + setInitializing: (loading) => { + set({ isInitializing: loading }); + }, + + setPanelsReady: (ready) => { + set({ isPanelsReady: ready }); + }, + + initializeApp: async () => { + console.log("Initializing video editor..."); + set({ isInitializing: true, isPanelsReady: false }); + + set({ isPanelsReady: true, isInitializing: false }); + console.log("Video editor ready"); + }, + + setCanvasSize: (size) => { + set({ canvasSize: size, canvasMode: "preset" }); + }, + + setCanvasSizeToOriginal: (aspectRatio) => { + const newCanvasSize = findBestCanvasPreset(aspectRatio); + set({ canvasSize: newCanvasSize, canvasMode: "original" }); + }, + + setCanvasSizeFromAspectRatio: (aspectRatio) => { + const newCanvasSize = findBestCanvasPreset(aspectRatio); + set({ canvasSize: newCanvasSize, canvasMode: "custom" }); + }, +})); diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index c4ee5f3e..029307d9 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -219,15 +219,19 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ mediaItems.map(async (item) => { if (item.type === "video" && item.file) { try { - const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file); + const { thumbnailUrl, width, height } = + await generateVideoThumbnail(item.file); return { ...item, thumbnailUrl, width: width || item.width, - height: height || item.height + height: height || item.height, }; } catch (error) { - console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error); + console.error( + `Failed to regenerate thumbnail for video ${item.id}:`, + error + ); return item; } } diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index 450b6a6e..b97235fa 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -1,45 +1,45 @@ -import { create } from "zustand";
-import { persist } from "zustand/middleware";
-
-const DEFAULT_PANEL_SIZES = {
- toolsPanel: 45,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
-} as const;
-
-interface PanelState {
- // Panel sizes as percentages
- toolsPanel: number;
- previewPanel: number;
- propertiesPanel: number;
- mainContent: number;
- timeline: number;
-
- // Actions
- setToolsPanel: (size: number) => void;
- setPreviewPanel: (size: number) => void;
- setPropertiesPanel: (size: number) => void;
- setMainContent: (size: number) => void;
- setTimeline: (size: number) => void;
-}
-
-export const usePanelStore = create<PanelState>()(
- persist(
- (set) => ({
- // Default sizes - optimized for responsiveness
- ...DEFAULT_PANEL_SIZES,
-
- // Actions
- setToolsPanel: (size) => set({ toolsPanel: size }),
- setPreviewPanel: (size) => set({ previewPanel: size }),
- setPropertiesPanel: (size) => set({ propertiesPanel: size }),
- setMainContent: (size) => set({ mainContent: size }),
- setTimeline: (size) => set({ timeline: size }),
- }),
- {
- name: "panel-sizes",
- }
- )
-);
+import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +const DEFAULT_PANEL_SIZES = { + toolsPanel: 45, + previewPanel: 75, + propertiesPanel: 20, + mainContent: 70, + timeline: 30, +} as const; + +interface PanelState { + // Panel sizes as percentages + toolsPanel: number; + previewPanel: number; + propertiesPanel: number; + mainContent: number; + timeline: number; + + // Actions + setToolsPanel: (size: number) => void; + setPreviewPanel: (size: number) => void; + setPropertiesPanel: (size: number) => void; + setMainContent: (size: number) => void; + setTimeline: (size: number) => void; +} + +export const usePanelStore = create<PanelState>()( + persist( + (set) => ({ + // Default sizes - optimized for responsiveness + ...DEFAULT_PANEL_SIZES, + + // Actions + setToolsPanel: (size) => set({ toolsPanel: size }), + setPreviewPanel: (size) => set({ previewPanel: size }), + setPropertiesPanel: (size) => set({ propertiesPanel: size }), + setMainContent: (size) => set({ mainContent: size }), + setTimeline: (size) => set({ timeline: size }), + }), + { + name: "panel-sizes", + } + ) +); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index a8833982..7bb38039 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,361 +1,360 @@ -import { TProject } from "@/types/project";
-import { create } from "zustand";
-import { storageService } from "@/lib/storage/storage-service";
-import { toast } from "sonner";
-import { useMediaStore } from "./media-store";
-import { useTimelineStore } from "./timeline-store";
-import { generateUUID } from "@/lib/utils";
-
-interface ProjectStore {
- activeProject: TProject | null;
- savedProjects: TProject[];
- isLoading: boolean;
- isInitialized: boolean;
-
- // Actions
- createNewProject: (name: string) => Promise<string>;
- loadProject: (id: string) => Promise<void>;
- saveCurrentProject: () => Promise<void>;
- loadAllProjects: () => Promise<void>;
- deleteProject: (id: string) => Promise<void>;
- closeProject: () => void;
- renameProject: (projectId: string, name: string) => Promise<void>;
- duplicateProject: (projectId: string) => Promise<string>;
- updateProjectBackground: (backgroundColor: string) => Promise<void>;
- updateBackgroundType: (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
- ) => Promise<void>;
- updateProjectFps: (fps: number) => Promise<void>;
-
- getFilteredAndSortedProjects: (
- searchQuery: string,
- sortOption: string
- ) => TProject[];
-}
-
-export const useProjectStore = create<ProjectStore>((set, get) => ({
- activeProject: null,
- savedProjects: [],
- isLoading: true,
- isInitialized: false,
-
- createNewProject: async (name: string) => {
- const newProject: TProject = {
- id: generateUUID(),
- name,
- thumbnail: "",
- createdAt: new Date(),
- updatedAt: new Date(),
- backgroundColor: "#000000",
- backgroundType: "color",
- blurIntensity: 8,
- };
-
- set({ activeProject: newProject });
-
- try {
- await storageService.saveProject(newProject);
- // Reload all projects to update the list
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- toast.error("Failed to save new project");
- throw error;
- }
- },
-
- loadProject: async (id: string) => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- // Clear media and timeline immediately to prevent flickering when switching projects
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
-
- try {
- const project = await storageService.loadProject(id);
- if (project) {
- set({ activeProject: project });
-
- // Load project-specific data in parallel
- await Promise.all([
- mediaStore.loadProjectMedia(id),
- timelineStore.loadProjectTimeline(id),
- ]);
- } else {
- throw new Error(`Project with id ${id} not found`);
- }
- } catch (error) {
- console.error("Failed to load project:", error);
- throw error; // Re-throw so the editor page can handle it
- } finally {
- set({ isLoading: false });
- }
- },
-
- saveCurrentProject: async () => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- try {
- // Save project metadata and timeline data in parallel
- const timelineStore = useTimelineStore.getState();
- await Promise.all([
- storageService.saveProject(activeProject),
- timelineStore.saveProjectTimeline(activeProject.id),
- ]);
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to save project:", error);
- }
- },
-
- loadAllProjects: async () => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- try {
- const projects = await storageService.loadAllProjects();
- set({ savedProjects: projects });
- } catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
- set({ isLoading: false, isInitialized: true });
- }
- },
-
- deleteProject: async (id: string) => {
- try {
- // Delete project data in parallel
- await Promise.all([
- storageService.deleteProjectMedia(id),
- storageService.deleteProjectTimeline(id),
- storageService.deleteProject(id),
- ]);
- await get().loadAllProjects(); // Refresh the list
-
- // If we deleted the active project, close it and clear data
- const { activeProject } = get();
- if (activeProject?.id === id) {
- set({ activeProject: null });
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- }
- } catch (error) {
- console.error("Failed to delete project:", error);
- }
- },
-
- closeProject: () => {
- set({ activeProject: null });
-
- // Clear data from stores when closing project
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- },
-
- renameProject: async (id: string, name: string) => {
- const { savedProjects } = get();
-
- // Find the project to rename
- const projectToRename = savedProjects.find((p) => p.id === id);
- if (!projectToRename) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- return;
- }
-
- const updatedProject = {
- ...projectToRename,
- name,
- updatedAt: new Date(),
- };
-
- try {
- // Save to storage
- await storageService.saveProject(updatedProject);
-
- await get().loadAllProjects();
-
- // Update activeProject if it's the same project
- const { activeProject } = get();
- if (activeProject?.id === id) {
- set({ activeProject: updatedProject });
- }
- } catch (error) {
- console.error("Failed to rename project:", error);
- toast.error("Failed to rename project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- }
- },
-
- duplicateProject: async (projectId: string) => {
- try {
- const project = await storageService.loadProject(projectId);
- if (!project) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- throw new Error("Project not found");
- }
-
- const { savedProjects } = get();
-
- // Extract the base name (remove any existing numbering)
- const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
- const baseName = numberMatch ? numberMatch[2] : project.name;
- const existingNumbers: number[] = [];
-
- // Check for pattern "(number) baseName" in existing projects
- savedProjects.forEach((p) => {
- const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
- if (match && match[2] === baseName) {
- existingNumbers.push(parseInt(match[1], 10));
- }
- });
-
- const nextNumber =
- existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
-
- const newProject: TProject = {
- id: generateUUID(),
- name: `(${nextNumber}) ${baseName}`,
- thumbnail: project.thumbnail,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- await storageService.saveProject(newProject);
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- console.error("Failed to duplicate project:", error);
- toast.error("Failed to duplicate project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- throw error;
- }
- },
-
- updateProjectBackground: async (backgroundColor: string) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundColor,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project background:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateBackgroundType: async (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
- ) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundType: type,
- ...(options?.backgroundColor && {
- backgroundColor: options.backgroundColor,
- }),
- ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update background type:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateProjectFps: async (fps: number) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- fps,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project FPS:", error);
- toast.error("Failed to update project FPS", {
- description: "Please try again",
- });
- }
- },
-
- getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
- const { savedProjects } = get();
-
- // Filter projects by search query
- const filteredProjects = savedProjects.filter((project) =>
- project.name.toLowerCase().includes(searchQuery.toLowerCase())
- );
-
- // Sort filtered projects
- const sortedProjects = [...filteredProjects].sort((a, b) => {
- const [key, order] = sortOption.split("-");
-
- if (key !== "createdAt" && key !== "name") {
- console.warn(`Invalid sort key: ${key}`);
- return 0;
- }
-
- const aValue = a[key];
- const bValue = b[key];
-
- if (aValue === undefined || bValue === undefined) return 0;
-
- if (order === "asc") {
- if (aValue < bValue) return -1;
- if (aValue > bValue) return 1;
- return 0;
- } else {
- if (aValue > bValue) return -1;
- if (aValue < bValue) return 1;
- return 0;
- }
- });
-
- return sortedProjects;
- },
-}));
+import { TProject } from "@/types/project"; +import { create } from "zustand"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; +import { useMediaStore } from "./media-store"; +import { useTimelineStore } from "./timeline-store"; +import { generateUUID } from "@/lib/utils"; + +interface ProjectStore { + activeProject: TProject | null; + savedProjects: TProject[]; + isLoading: boolean; + isInitialized: boolean; + + // Actions + createNewProject: (name: string) => Promise<string>; + loadProject: (id: string) => Promise<void>; + saveCurrentProject: () => Promise<void>; + loadAllProjects: () => Promise<void>; + deleteProject: (id: string) => Promise<void>; + closeProject: () => void; + renameProject: (projectId: string, name: string) => Promise<void>; + duplicateProject: (projectId: string) => Promise<string>; + updateProjectBackground: (backgroundColor: string) => Promise<void>; + updateBackgroundType: ( + type: "color" | "blur", + options?: { backgroundColor?: string; blurIntensity?: number } + ) => Promise<void>; + updateProjectFps: (fps: number) => Promise<void>; + + getFilteredAndSortedProjects: ( + searchQuery: string, + sortOption: string + ) => TProject[]; +} + +export const useProjectStore = create<ProjectStore>((set, get) => ({ + activeProject: null, + savedProjects: [], + isLoading: true, + isInitialized: false, + + createNewProject: async (name: string) => { + const newProject: TProject = { + id: generateUUID(), + name, + thumbnail: "", + createdAt: new Date(), + updatedAt: new Date(), + backgroundColor: "#000000", + backgroundType: "color", + blurIntensity: 8, + }; + + set({ activeProject: newProject }); + + try { + await storageService.saveProject(newProject); + // Reload all projects to update the list + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + toast.error("Failed to save new project"); + throw error; + } + }, + + loadProject: async (id: string) => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + // Clear media and timeline immediately to prevent flickering when switching projects + const mediaStore = useMediaStore.getState(); + const timelineStore = useTimelineStore.getState(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + + try { + const project = await storageService.loadProject(id); + if (project) { + set({ activeProject: project }); + + // Load project-specific data in parallel + await Promise.all([ + mediaStore.loadProjectMedia(id), + timelineStore.loadProjectTimeline(id), + ]); + } else { + throw new Error(`Project with id ${id} not found`); + } + } catch (error) { + console.error("Failed to load project:", error); + throw error; // Re-throw so the editor page can handle it + } finally { + set({ isLoading: false }); + } + }, + + saveCurrentProject: async () => { + const { activeProject } = get(); + if (!activeProject) return; + + try { + // Save project metadata and timeline data in parallel + const timelineStore = useTimelineStore.getState(); + await Promise.all([ + storageService.saveProject(activeProject), + timelineStore.saveProjectTimeline(activeProject.id), + ]); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to save project:", error); + } + }, + + loadAllProjects: async () => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + try { + const projects = await storageService.loadAllProjects(); + set({ savedProjects: projects }); + } catch (error) { + console.error("Failed to load projects:", error); + } finally { + set({ isLoading: false, isInitialized: true }); + } + }, + + deleteProject: async (id: string) => { + try { + // Delete project data in parallel + await Promise.all([ + storageService.deleteProjectMedia(id), + storageService.deleteProjectTimeline(id), + storageService.deleteProject(id), + ]); + await get().loadAllProjects(); // Refresh the list + + // If we deleted the active project, close it and clear data + const { activeProject } = get(); + if (activeProject?.id === id) { + set({ activeProject: null }); + const mediaStore = useMediaStore.getState(); + const timelineStore = useTimelineStore.getState(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + } + } catch (error) { + console.error("Failed to delete project:", error); + } + }, + + closeProject: () => { + set({ activeProject: null }); + + // Clear data from stores when closing project + const mediaStore = useMediaStore.getState(); + const timelineStore = useTimelineStore.getState(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + }, + + renameProject: async (id: string, name: string) => { + const { savedProjects } = get(); + + // Find the project to rename + const projectToRename = savedProjects.find((p) => p.id === id); + if (!projectToRename) { + toast.error("Project not found", { + description: "Please try again", + }); + return; + } + + const updatedProject = { + ...projectToRename, + name, + updatedAt: new Date(), + }; + + try { + // Save to storage + await storageService.saveProject(updatedProject); + + await get().loadAllProjects(); + + // Update activeProject if it's the same project + const { activeProject } = get(); + if (activeProject?.id === id) { + set({ activeProject: updatedProject }); + } + } catch (error) { + console.error("Failed to rename project:", error); + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + }, + + duplicateProject: async (projectId: string) => { + try { + const project = await storageService.loadProject(projectId); + if (!project) { + toast.error("Project not found", { + description: "Please try again", + }); + throw new Error("Project not found"); + } + + const { savedProjects } = get(); + + // Extract the base name (remove any existing numbering) + const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/); + const baseName = numberMatch ? numberMatch[2] : project.name; + const existingNumbers: number[] = []; + + // Check for pattern "(number) baseName" in existing projects + savedProjects.forEach((p) => { + const match = p.name.match(/^\((\d+)\)\s+(.+)$/); + if (match && match[2] === baseName) { + existingNumbers.push(parseInt(match[1], 10)); + } + }); + + const nextNumber = + existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; + + const newProject: TProject = { + id: generateUUID(), + name: `(${nextNumber}) ${baseName}`, + thumbnail: project.thumbnail, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await storageService.saveProject(newProject); + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + console.error("Failed to duplicate project:", error); + toast.error("Failed to duplicate project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + throw error; + } + }, + + updateProjectBackground: async (backgroundColor: string) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + backgroundColor, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project background:", error); + toast.error("Failed to update background", { + description: "Please try again", + }); + } + }, + + updateBackgroundType: async ( + type: "color" | "blur", + options?: { backgroundColor?: string; blurIntensity?: number } + ) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + backgroundType: type, + ...(options?.backgroundColor && { + backgroundColor: options.backgroundColor, + }), + ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update background type:", error); + toast.error("Failed to update background", { + description: "Please try again", + }); + } + }, + + updateProjectFps: async (fps: number) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + fps, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project FPS:", error); + toast.error("Failed to update project FPS", { + description: "Please try again", + }); + } + }, + + getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { + const { savedProjects } = get(); + + // Filter projects by search query + const filteredProjects = savedProjects.filter((project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort filtered projects + const sortedProjects = [...filteredProjects].sort((a, b) => { + const [key, order] = sortOption.split("-"); + + if (key !== "createdAt" && key !== "name") { + console.warn(`Invalid sort key: ${key}`); + return 0; + } + + const aValue = a[key]; + const bValue = b[key]; + + if (aValue === undefined || bValue === undefined) return 0; + + if (order === "asc") { + if (aValue < bValue) return -1; + if (aValue > bValue) return 1; + return 0; + } + if (aValue > bValue) return -1; + if (aValue < bValue) return 1; + return 0; + }); + + return sortedProjects; + }, +})); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 8a4abc6a..820025cc 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -295,9 +295,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { { trackId, elementId }, ], }; - } else { - return { selectedElements: [{ trackId, elementId }] }; } + return { selectedElements: [{ trackId, elementId }] }; }); }, @@ -680,7 +679,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { (element) => element.id !== elementId ), }; - } else if (track.id === toTrackId) { + } + if (track.id === toTrackId) { return { ...track, elements: [...track.elements, elementToMove], @@ -1091,7 +1091,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { if (!fileType) return false; // Process the new media file - let mediaData: any = { + const mediaData: any = { name: newFile.name, type: fileType, file: newFile, @@ -1204,7 +1204,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); return thumbnailUrl; - } else if (mediaItem.type === "image" && mediaItem.url) { + } + if (mediaItem.type === "image" && mediaItem.url) { return mediaItem.url; } diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index dce7ac50..b0449991 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -1,12 +1,12 @@ -export type BackgroundType = "blur" | "mirror" | "color";
-
-export interface CanvasSize {
- width: number;
- height: number;
-}
-
-export interface CanvasPreset {
- name: string;
- width: number;
- height: number;
-}
+export type BackgroundType = "blur" | "mirror" | "color"; + +export interface CanvasSize { + width: number; + height: number; +} + +export interface CanvasPreset { + name: string; + width: number; + height: number; +} diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts index d97c88da..831f3abd 100644 --- a/apps/web/src/types/keybinding.ts +++ b/apps/web/src/types/keybinding.ts @@ -16,12 +16,58 @@ export type ModifierKeys = /* eslint-disable prettier/prettier */ // prettier-ignore export type Key = - | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" - | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" - | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" - | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left" - | "right" | "/" | "?" | "." | "enter" | "tab" | "space" - | "escape" | "esc" | "backspace" | "delete" | "home" | "end" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "up" + | "down" + | "left" + | "right" + | "/" + | "?" + | "." + | "enter" + | "tab" + | "space" + | "escape" + | "esc" + | "backspace" + | "delete" + | "home" + | "end"; /* eslint-enable */ export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; diff --git a/apps/web/src/types/post.ts b/apps/web/src/types/post.ts index c3436847..fbee239e 100644 --- a/apps/web/src/types/post.ts +++ b/apps/web/src/types/post.ts @@ -1,93 +1,92 @@ 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; + slug: string; + title: string; + content: string; + description: string; + coverImage: string; + publishedAt: Date; + updatedAt: Date; + authors: { id: string; name: string; - slug: string; - }; - - export type MarbleTag = { - tag: Tag; - }; - - export type MarbleTagList = { - tags: Tag[]; - pagination: Pagination; - }; - - export type Category = { + image: string; + }[]; + category: { id: string; - name: string; slug: string; + name: string; }; - - export type MarbleCategory = { - category: Category; - }; - - export type MarbleCategoryList = { - categories: Category[]; - pagination: Pagination; - }; - - export type Author = { + tags: { id: string; + slug: string; name: string; - image: string; - }; - - export type MarbleAuthor = { - author: Author; - }; - - export type MarbleAuthorList = { - authors: Author[]; - pagination: Pagination; - }; -
\ No newline at end of file + }[]; + 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; +}; diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index f8a79d03..ef20c642 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -138,7 +138,8 @@ export function canElementGoOnTrack( ): boolean { if (elementType === "text") { return trackType === "text"; - } else if (elementType === "media") { + } + if (elementType === "media") { return trackType === "media" || trackType === "audio"; } return false; |
