diff options
Diffstat (limited to 'apps/web/src/app')
| -rw-r--r-- | apps/web/src/app/(auth)/login/page.tsx | 248 | ||||
| -rw-r--r-- | apps/web/src/app/(auth)/signup/page.tsx | 282 | ||||
| -rw-r--r-- | apps/web/src/app/api/auth/[...all]/route.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/app/api/health/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/app/api/waitlist/route.ts | 188 | ||||
| -rw-r--r-- | apps/web/src/app/api/waitlist/token/route.ts | 75 | ||||
| -rw-r--r-- | apps/web/src/app/contributors/page.tsx | 554 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 222 | ||||
| -rw-r--r-- | apps/web/src/app/globals.css | 158 | ||||
| -rw-r--r-- | apps/web/src/app/layout.tsx | 68 | ||||
| -rw-r--r-- | apps/web/src/app/metadata.ts | 158 | ||||
| -rw-r--r-- | apps/web/src/app/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/app/privacy/page.tsx | 506 | ||||
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 1086 | ||||
| -rw-r--r-- | apps/web/src/app/roadmap/page.tsx | 494 | ||||
| -rw-r--r-- | apps/web/src/app/terms/page.tsx | 634 | ||||
| -rw-r--r-- | apps/web/src/app/why-not-capcut/page.tsx | 382 |
17 files changed, 2521 insertions, 2558 deletions
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 170be83f..f28cd191 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -3,11 +3,11 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { memo, Suspense } from "react"; import { Input } from "@/components/ui/input"; @@ -20,127 +20,127 @@ import { GoogleIcon } from "@/components/icons"; import { useLogin } from "@/hooks/auth/useLogin"; const LoginPage = () => { - const router = useRouter(); - const { - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleLogin, - handleGoogleLogin, - } = useLogin(); + const router = useRouter(); + const { + email, + setEmail, + password, + setPassword, + error, + isAnyLoading, + isEmailLoading, + isGoogleLoading, + handleLogin, + handleGoogleLogin, + } = useLogin(); - return ( - <div className="flex h-screen items-center justify-center relative"> - <Button - variant="text" - onClick={() => router.back()} - className="absolute top-6 left-6" - > - <ArrowLeft className="h-5 w-5" /> Back - </Button> - <Card className="w-[400px] shadow-lg border-0"> - <CardHeader className="text-center pb-4"> - <CardTitle className="text-2xl font-semibold">Welcome back</CardTitle> - <CardDescription className="text-base"> - Sign in to your account to continue - </CardDescription> - </CardHeader> - <CardContent className="pt-0"> - <Suspense - fallback={ - <div className="text-center"> - <Loader2 className="animate-spin" /> - </div> - } - > - <div className="flex flex-col space-y-6"> - {error && ( - <Alert variant="destructive"> - <AlertTitle>Error</AlertTitle> - <AlertDescription>{error}</AlertDescription> - </Alert> - )} + return ( + <div className="flex h-screen items-center justify-center relative"> + <Button + variant="text" + onClick={() => router.back()} + className="absolute top-6 left-6" + > + <ArrowLeft className="h-5 w-5" /> Back + </Button> + <Card className="w-[400px] shadow-lg border-0"> + <CardHeader className="text-center pb-4"> + <CardTitle className="text-2xl font-semibold">Welcome back</CardTitle> + <CardDescription className="text-base"> + Sign in to your account to continue + </CardDescription> + </CardHeader> + <CardContent className="pt-0"> + <Suspense + fallback={ + <div className="text-center"> + <Loader2 className="animate-spin" /> + </div> + } + > + <div className="flex flex-col space-y-6"> + {error && ( + <Alert variant="destructive"> + <AlertTitle>Error</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} - <Button - onClick={handleGoogleLogin} - variant="outline" - size="lg" - disabled={isAnyLoading} - > - {isGoogleLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - <GoogleIcon /> - )}{" "} - Continue with Google - </Button> - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <Separator className="w-full" /> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - Or continue with - </span> - </div> - </div> - <div className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" - type="email" - placeholder="m@example.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <Button - onClick={handleLogin} - disabled={isAnyLoading || !email || !password} - className="w-full h-11" - size="lg" - > - {isEmailLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - "Sign in" - )} - </Button> - </div> - </div> - <div className="mt-6 text-center text-sm"> - Don't have an account?{" "} - <Link - href="/signup" - className="font-medium text-primary underline-offset-4 hover:underline" - > - Sign up - </Link> - </div> - </Suspense> - </CardContent> - </Card> - </div> - ); + <Button + onClick={handleGoogleLogin} + variant="outline" + size="lg" + disabled={isAnyLoading} + > + {isGoogleLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + <GoogleIcon /> + )}{" "} + Continue with Google + </Button> + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <Separator className="w-full" /> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + Or continue with + </span> + </div> + </div> + <div className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="m@example.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <Button + onClick={handleLogin} + disabled={isAnyLoading || !email || !password} + className="w-full h-11" + size="lg" + > + {isEmailLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + "Sign in" + )} + </Button> + </div> + </div> + <div className="mt-6 text-center text-sm"> + Don't have an account?{" "} + <Link + href="/signup" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign up + </Link> + </div> + </Suspense> + </CardContent> + </Card> + </div> + ); }; export default memo(LoginPage); diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx index a3cd1faf..c3a0f899 100644 --- a/apps/web/src/app/(auth)/signup/page.tsx +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -3,11 +3,11 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { memo, Suspense } from "react"; import { Input } from "@/components/ui/input"; @@ -20,143 +20,143 @@ import { GoogleIcon } from "@/components/icons"; import { useSignUp } from "@/hooks/auth/useSignUp"; const SignUpPage = () => { - const router = useRouter(); - const { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleSignUp, - handleGoogleSignUp, - } = useSignUp(); + const router = useRouter(); + const { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isAnyLoading, + isEmailLoading, + isGoogleLoading, + handleSignUp, + handleGoogleSignUp, + } = useSignUp(); - return ( - <div className="flex h-screen items-center justify-center relative"> - <Button - variant="text" - onClick={() => router.back()} - className="absolute top-6 left-6" - > - <ArrowLeft className="h-5 w-5" /> Back - </Button> - <Card className="w-[400px] shadow-lg border-0"> - <CardHeader className="text-center pb-4"> - <CardTitle className="text-2xl font-semibold"> - Create your account - </CardTitle> - <CardDescription className="text-base"> - Get started with your free account today - </CardDescription> - </CardHeader> - <CardContent className="pt-0"> - <Suspense - fallback={ - <div className="text-center"> - <Loader2 className="animate-spin" /> - </div> - } - > - <div className="flex flex-col space-y-6"> - {error && ( - <Alert variant="destructive"> - <AlertTitle>Error</AlertTitle> - <AlertDescription>{error}</AlertDescription> - </Alert> - )} - <Button - onClick={handleGoogleSignUp} - variant="outline" - size="lg" - disabled={isAnyLoading} - > - {isGoogleLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - <GoogleIcon /> - )}{" "} - Continue with Google - </Button> - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <Separator className="w-full" /> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - Or continue with - </span> - </div> - </div> - <div className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="name">Full Name</Label> - <Input - id="name" - type="text" - placeholder="John Doe" - value={name} - onChange={(e) => setName(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" - type="email" - placeholder="m@example.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" - type="password" - placeholder="Create a strong password" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <Button - onClick={handleSignUp} - disabled={isAnyLoading || !name || !email || !password} - className="w-full h-11" - size="lg" - > - {isEmailLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - "Create account" - )} - </Button> - </div> - </div> - <div className="mt-6 text-center text-sm"> - Already have an account?{" "} - <Link - href="/login" - className="font-medium text-primary underline-offset-4 hover:underline" - > - Sign in - </Link> - </div> - </Suspense> - </CardContent> - </Card> - </div> - ); + return ( + <div className="flex h-screen items-center justify-center relative"> + <Button + variant="text" + onClick={() => router.back()} + className="absolute top-6 left-6" + > + <ArrowLeft className="h-5 w-5" /> Back + </Button> + <Card className="w-[400px] shadow-lg border-0"> + <CardHeader className="text-center pb-4"> + <CardTitle className="text-2xl font-semibold"> + Create your account + </CardTitle> + <CardDescription className="text-base"> + Get started with your free account today + </CardDescription> + </CardHeader> + <CardContent className="pt-0"> + <Suspense + fallback={ + <div className="text-center"> + <Loader2 className="animate-spin" /> + </div> + } + > + <div className="flex flex-col space-y-6"> + {error && ( + <Alert variant="destructive"> + <AlertTitle>Error</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} + <Button + onClick={handleGoogleSignUp} + variant="outline" + size="lg" + disabled={isAnyLoading} + > + {isGoogleLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + <GoogleIcon /> + )}{" "} + Continue with Google + </Button> + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <Separator className="w-full" /> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + Or continue with + </span> + </div> + </div> + <div className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="name">Full Name</Label> + <Input + id="name" + type="text" + placeholder="John Doe" + value={name} + onChange={(e) => setName(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="m@example.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + placeholder="Create a strong password" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <Button + onClick={handleSignUp} + disabled={isAnyLoading || !name || !email || !password} + className="w-full h-11" + size="lg" + > + {isEmailLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + "Create account" + )} + </Button> + </div> + </div> + <div className="mt-6 text-center text-sm"> + Already have an account?{" "} + <Link + href="/login" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign in + </Link> + </div> + </Suspense> + </CardContent> + </Card> + </div> + ); }; export default memo(SignUpPage); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 5deef204..55510dcb 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); +import { auth } from "@opencut/auth";
+import { toNextJsHandler } from "better-auth/next-js";
+
+export const { POST, GET } = toNextJsHandler(auth);
\ No newline at end of file diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index f8c951fe..ecb9ce04 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; export async function GET(request: NextRequest) { - return new Response("OK", { status: 200 }); + return new Response("OK", { status: 200 }); } diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts index 98232e49..e5c9fd94 100644 --- a/apps/web/src/app/api/waitlist/route.ts +++ b/apps/web/src/app/api/waitlist/route.ts @@ -1,105 +1,83 @@ -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 6f5ee18f..4c7695fa 100644 --- a/apps/web/src/app/api/waitlist/token/route.ts +++ b/apps/web/src/app/api/waitlist/token/route.ts @@ -5,57 +5,42 @@ 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"); - const host = request.headers.get("host"); + const referer = request.headers.get("referer"); + const host = request.headers.get("host"); - if (referer) { - const refererUrl = new URL(referer); + if (referer) { + const refererUrl = new URL(referer); - 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), - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + 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))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } - if (!env.BETTER_AUTH_SECRET) { - throw new Error("BETTER_AUTH_SECRET must be configured"); - } + if (!env.BETTER_AUTH_SECRET) { + throw new Error("BETTER_AUTH_SECRET must be configured"); + } - 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 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 cookieStore = await cookies(); - cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict", - maxAge: TOKEN_EXPIRY / 1000, - path: "/", - }); + const cookieStore = await cookies(); + cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + maxAge: TOKEN_EXPIRY / 1000, + path: "/", + }); - return NextResponse.json({ token }); + return NextResponse.json({ token }); } diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 2c602e25..5613f1f9 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/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 6c7186bc..aa5b7315 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -3,9 +3,9 @@ import { useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, } from "../../../components/ui/resizable"; import { MediaPanel } from "../../../components/editor/media-panel"; import { PropertiesPanel } from "../../../components/editor/properties-panel"; @@ -19,127 +19,127 @@ import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { Onboarding } from "@/components/onboarding"; export default function Editor() { - const { - toolsPanel, - previewPanel, - mainContent, - timeline, - setToolsPanel, - setPreviewPanel, - setMainContent, - setTimeline, - propertiesPanel, - setPropertiesPanel, - } = usePanelStore(); + const { + toolsPanel, + previewPanel, + mainContent, + timeline, + setToolsPanel, + setPreviewPanel, + setMainContent, + setTimeline, + propertiesPanel, + setPropertiesPanel, + } = usePanelStore(); - const { activeProject, loadProject, createNewProject } = useProjectStore(); - const params = useParams(); - const router = useRouter(); - const projectId = params.project_id as string; - const handledProjectIds = useRef<Set<string>>(new Set()); - const [isOnboardingOpen, setIsOnboardingOpen] = useState(true); + const { activeProject, loadProject, createNewProject } = useProjectStore(); + const params = useParams(); + const router = useRouter(); + const projectId = params.project_id as string; + const handledProjectIds = useRef<Set<string>>(new Set()); + const [isOnboardingOpen, setIsOnboardingOpen] = useState(true); - usePlaybackControls(); + usePlaybackControls(); - useEffect(() => { - const initProject = async () => { - if (!projectId) return; + useEffect(() => { + const initProject = async () => { + if (!projectId) return; - if (activeProject?.id === projectId) { - return; - } + if (activeProject?.id === projectId) { + return; + } - if (handledProjectIds.current.has(projectId)) { - return; - } + if (handledProjectIds.current.has(projectId)) { + return; + } - try { - await loadProject(projectId); - } catch (error) { - handledProjectIds.current.add(projectId); + try { + await loadProject(projectId); + } catch (error) { + handledProjectIds.current.add(projectId); - const newProjectId = await createNewProject("Untitled Project"); - router.replace(`/editor/${newProjectId}`); - return; - } - }; + const newProjectId = await createNewProject("Untitled Project"); + router.replace(`/editor/${newProjectId}`); + return; + } + }; - initProject(); - }, [projectId, activeProject?.id, loadProject, createNewProject, router]); + initProject(); + }, [projectId, activeProject?.id, loadProject, createNewProject, router]); - return ( - <EditorProvider> - <div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> - <EditorHeader /> - <div className="flex-1 min-h-0 min-w-0"> - <ResizablePanelGroup - direction="vertical" - className="h-full w-full gap-[0.18rem]" - > - <ResizablePanel - defaultSize={mainContent} - minSize={30} - maxSize={85} - onResize={setMainContent} - className="min-h-0" - > - {/* Main content area */} - <ResizablePanelGroup - direction="horizontal" - className="h-full w-full gap-[0.19rem] px-2" - > - {/* Tools Panel */} - <ResizablePanel - defaultSize={toolsPanel} - minSize={15} - maxSize={40} - onResize={setToolsPanel} - className="min-w-0" - > - <MediaPanel /> - </ResizablePanel> + return ( + <EditorProvider> + <div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> + <EditorHeader /> + <div className="flex-1 min-h-0 min-w-0"> + <ResizablePanelGroup + direction="vertical" + className="h-full w-full gap-[0.18rem]" + > + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + {/* Main content area */} + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem] px-2" + > + {/* Tools Panel */} + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0" + > + <MediaPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Preview Area */} - <ResizablePanel - defaultSize={previewPanel} - minSize={30} - onResize={setPreviewPanel} - className="min-w-0 min-h-0 flex-1" - > - <PreviewPanel /> - </ResizablePanel> + {/* Preview Area */} + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - <ResizablePanel - defaultSize={propertiesPanel} - minSize={15} - maxSize={40} - onResize={setPropertiesPanel} - className="min-w-0" - > - <PropertiesPanel /> - </ResizablePanel> - </ResizablePanelGroup> - </ResizablePanel> + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Timeline */} - <ResizablePanel - defaultSize={timeline} - minSize={15} - maxSize={70} - onResize={setTimeline} - className="min-h-0 px-2 pb-2" - > - <Timeline /> - </ResizablePanel> - </ResizablePanelGroup> - </div> - </div> - </EditorProvider> - ); + {/* Timeline */} + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0 px-2 pb-2" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + </div> + </div> + </EditorProvider> + ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e7c575a1..2f7158cc 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -3,86 +3,86 @@ @tailwind utilities; @layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 1rem; - --sidebar-background: 0 0% 100%; - --sidebar-foreground: 0 0% 3.9%; - --sidebar-primary: 0 0% 9%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 96.1%; - --sidebar-accent-foreground: 0 0% 9%; - --sidebar-border: 0 0% 89.8%; - --sidebar-ring: 0 0% 3.9%; - } - .dark { - --background: 0 0% 4%; - --foreground: 0 0% 89%; - --card: 0 0% 14.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 14.9%; - --popover-foreground: 0 0% 98%; - --primary: 180 95% 40%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 100% 60%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 17%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 0 0% 3.9%; - --sidebar-foreground: 0 0% 98%; - --sidebar-primary: 0 0% 98%; - --sidebar-primary-foreground: 0 0% 9%; - --sidebar-accent: 0 0% 14.9%; - --sidebar-accent-foreground: 0 0% 98%; - --sidebar-border: 0 0% 14.9%; - --sidebar-ring: 0 0% 83.1%; - --panel-background: 0 0% 11%; - --panel-accent: 0 0% 15%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 1rem; + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 0 0% 3.9%; + --sidebar-primary: 0 0% 9%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 0 0% 96.1%; + --sidebar-accent-foreground: 0 0% 9%; + --sidebar-border: 0 0% 89.8%; + --sidebar-ring: 0 0% 3.9%; + } + .dark { + --background: 0 0% 4%; + --foreground: 0 0% 89%; + --card: 0 0% 14.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 14.9%; + --popover-foreground: 0 0% 98%; + --primary: 180 95% 40%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 100% 60%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 17%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 0 0% 3.9%; + --sidebar-foreground: 0 0% 98%; + --sidebar-primary: 0 0% 98%; + --sidebar-primary-foreground: 0 0% 9%; + --sidebar-accent: 0 0% 14.9%; + --sidebar-accent-foreground: 0 0% 98%; + --sidebar-border: 0 0% 14.9%; + --sidebar-ring: 0 0% 83.1%; + --panel-background: 0 0% 11%; + --panel-accent: 0 0% 15%; + } } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - /* Prevent back/forward swipe */ - overscroll-behavior-x: contain; - } + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + /* Prevent back/forward swipe */ + overscroll-behavior-x: contain; + } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 41c84304..5c1d7520 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -13,43 +13,43 @@ import { env } from "@/env"; export const metadata = baseMetaData; const protectedRoutes = [ - { - path: "/api/waitlist", - method: "POST", - }, + { + path: "/api/waitlist", + method: "POST", + }, ]; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - <html lang="en" suppressHydrationWarning> - <head> - <BotIdClient protect={protectedRoutes} /> - </head> - <body className={`${defaultFont.className} font-sans antialiased`}> - <ThemeProvider attribute="class" forcedTheme="dark"> - <TooltipProvider> - <StorageProvider>{children}</StorageProvider> - <Analytics /> - <Toaster /> - <Script - src="https://cdn.databuddy.cc/databuddy.js" - strategy="afterInteractive" - async - data-client-id="UP-Wcoy5arxFeK7oyjMMZ" - data-disabled={env.NODE_ENV === "development"} - data-track-attributes={false} - data-track-errors={true} - data-track-outgoing-links={false} - data-track-web-vitals={false} - data-track-sessions={false} - /> - </TooltipProvider> - </ThemeProvider> - </body> - </html> - ); + return ( + <html lang="en" suppressHydrationWarning> + <head> + <BotIdClient protect={protectedRoutes} /> + </head> + <body className={`${defaultFont.className} font-sans antialiased`}> + <ThemeProvider attribute="class" forcedTheme="dark"> + <TooltipProvider> + <StorageProvider>{children}</StorageProvider> + <Analytics /> + <Toaster /> + <Script + src="https://cdn.databuddy.cc/databuddy.js" + strategy="afterInteractive" + async + data-client-id="UP-Wcoy5arxFeK7oyjMMZ" + data-disabled={env.NODE_ENV === "development"} + data-track-attributes={false} + data-track-errors={true} + data-track-outgoing-links={false} + data-track-web-vitals={false} + data-track-sessions={false} + /> + </TooltipProvider> + </ThemeProvider> + </body> + </html> + ); } diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts index adff976f..47f0020b 100644 --- a/apps/web/src/app/metadata.ts +++ b/apps/web/src/app/metadata.ts @@ -2,87 +2,87 @@ import { Metadata } from "next"; const title = "OpenCut"; const description = - "A simple but powerful video editor that gets the job done. In your browser."; + "A simple but powerful video editor that gets the job done. In your browser."; const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg"; const twitterImageUrl = "/open-graph/default.jpg"; export const baseMetaData: Metadata = { - metadataBase: new URL("https://opencut.app"), - title: title, - description: description, - openGraph: { - title: title, - description: description, - url: "https://opencut.app", - siteName: "OpenCut", - locale: "en_US", - type: "website", - images: [ - { - url: openGraphImageUrl, - width: 1200, - height: 630, - alt: "OpenCut", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: title, - description: description, - creator: "@opencutapp", - images: [twitterImageUrl], - }, - robots: { - index: true, - follow: true, - }, - icons: { - icon: [ - { url: "/favicon.ico" }, - { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, - { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, - { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, - ], - apple: [ - { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, - { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, - { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, - { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, - { - url: "/icons/apple-icon-114x114.png", - sizes: "114x114", - type: "image/png", - }, - { - url: "/icons/apple-icon-120x120.png", - sizes: "120x120", - type: "image/png", - }, - { - url: "/icons/apple-icon-144x144.png", - sizes: "144x144", - type: "image/png", - }, - { - url: "/icons/apple-icon-152x152.png", - sizes: "152x152", - type: "image/png", - }, - { - url: "/icons/apple-icon-180x180.png", - sizes: "180x180", - type: "image/png", - }, - ], - shortcut: ["/favicon.ico"], - }, - appleWebApp: { - capable: true, - title: title, - }, - manifest: "/manifest.json", - other: { - "msapplication-config": "/browserconfig.xml", - }, + metadataBase: new URL("https://opencut.app"), + title: title, + description: description, + openGraph: { + title: title, + description: description, + url: "https://opencut.app", + siteName: "OpenCut", + locale: "en_US", + type: "website", + images: [ + { + url: openGraphImageUrl, + width: 1200, + height: 630, + alt: "OpenCut", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: title, + description: description, + creator: "@opencutapp", + images: [twitterImageUrl], + }, + robots: { + index: true, + follow: true, + }, + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, + ], + apple: [ + { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, + { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, + { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, + { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, + { + url: "/icons/apple-icon-114x114.png", + sizes: "114x114", + type: "image/png", + }, + { + url: "/icons/apple-icon-120x120.png", + sizes: "120x120", + type: "image/png", + }, + { + url: "/icons/apple-icon-144x144.png", + sizes: "144x144", + type: "image/png", + }, + { + url: "/icons/apple-icon-152x152.png", + sizes: "152x152", + type: "image/png", + }, + { + url: "/icons/apple-icon-180x180.png", + sizes: "180x180", + type: "image/png", + }, + ], + shortcut: ["/favicon.ico"], + }, + appleWebApp: { + capable: true, + title: title, + }, + manifest: "/manifest.json", + other: { + "msapplication-config": "/browserconfig.xml", + }, }; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f1c78a01..74961549 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -3,11 +3,11 @@ import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; export default async function Home() { - return ( - <div> - <Header /> - <Hero /> - <Footer /> - </div> - ); + return ( + <div> + <Header /> + <Hero /> + <Footer /> + </div> + ); } diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 94a7fa77..42dd0f9a 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/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 4dd15ee7..7ac08107 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -7,601 +7,601 @@ import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { - ChevronLeft, - Plus, - Calendar, - MoreHorizontal, - Video, - Loader2, - X, - Trash2, - Search, + ChevronLeft, + Plus, + Calendar, + MoreHorizontal, + Video, + Loader2, + X, + Trash2, + Search, } from "lucide-react"; import { TProject } from "@/types/project"; import Image from "next/image"; import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuSeparator, + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { useProjectStore } from "@/stores/project-store"; import { useRouter } from "next/navigation"; import { DeleteProjectDialog } from "@/components/delete-project-dialog"; import { RenameProjectDialog } from "@/components/rename-project-dialog"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; export default function ProjectsPage() { - const { - createNewProject, - savedProjects, - isLoading, - isInitialized, - deleteProject, - getFilteredAndSortedProjects, - } = useProjectStore(); - const router = useRouter(); - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [selectedProjects, setSelectedProjects] = useState<Set<string>>( - new Set(), - ); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [sortOption, setSortOption] = useState("createdAt-desc"); + const { + createNewProject, + savedProjects, + isLoading, + isInitialized, + deleteProject, + getFilteredAndSortedProjects, + } = useProjectStore(); + const router = useRouter(); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedProjects, setSelectedProjects] = useState<Set<string>>( + new Set() + ); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sortOption, setSortOption] = useState("createdAt-desc"); - const handleCreateProject = async () => { - const projectId = await createNewProject("New Project"); - console.log("projectId", projectId); - router.push(`/editor/${projectId}`); - }; + const handleCreateProject = async () => { + const projectId = await createNewProject("New Project"); + console.log("projectId", projectId); + router.push(`/editor/${projectId}`); + }; - const handleSelectProject = (projectId: string, checked: boolean) => { - const newSelected = new Set(selectedProjects); - if (checked) { - newSelected.add(projectId); - } else { - newSelected.delete(projectId); - } - setSelectedProjects(newSelected); - }; + const handleSelectProject = (projectId: string, checked: boolean) => { + const newSelected = new Set(selectedProjects); + if (checked) { + newSelected.add(projectId); + } else { + newSelected.delete(projectId); + } + setSelectedProjects(newSelected); + }; - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedProjects(new Set(sortedProjects.map((p) => p.id))); - } else { - setSelectedProjects(new Set()); - } - }; + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedProjects(new Set(sortedProjects.map((p) => p.id))); + } else { + setSelectedProjects(new Set()); + } + }; - const handleCancelSelection = () => { - setIsSelectionMode(false); - setSelectedProjects(new Set()); - }; + const handleCancelSelection = () => { + setIsSelectionMode(false); + setSelectedProjects(new Set()); + }; - const handleBulkDelete = async () => { - await Promise.all( - Array.from(selectedProjects).map((projectId) => deleteProject(projectId)), - ); - setSelectedProjects(new Set()); - setIsSelectionMode(false); - setIsBulkDeleteDialogOpen(false); - }; + const handleBulkDelete = async () => { + await Promise.all( + Array.from(selectedProjects).map((projectId) => deleteProject(projectId)) + ); + setSelectedProjects(new Set()); + setIsSelectionMode(false); + setIsBulkDeleteDialogOpen(false); + }; - const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); + const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); - const allSelected = - sortedProjects.length > 0 && - selectedProjects.size === sortedProjects.length; - const someSelected = - selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; + const allSelected = + sortedProjects.length > 0 && + selectedProjects.size === sortedProjects.length; + const someSelected = + selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; - return ( - <div className="min-h-screen bg-background"> - <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> - <Link - href="/" - className="flex items-center gap-1 hover:text-muted-foreground transition-colors" - > - <ChevronLeft className="!size-5 shrink-0" /> - <span className="text-sm font-medium">Back</span> - </Link> - <div className="block md:hidden"> - {isSelectionMode ? ( - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleCancelSelection} - > - <X className="!size-4" /> - Cancel - </Button> - {selectedProjects.size > 0 && ( - <Button - variant="destructive" - size="sm" - onClick={() => setIsBulkDeleteDialogOpen(true)} - > - <Trash2 className="!size-4" /> - Delete ({selectedProjects.size}) - </Button> - )} - </div> - ) : ( - <CreateButton onClick={handleCreateProject} /> - )} - </div> - </div> - <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> - <div className="mb-8 flex items-center justify-between"> - <div className="flex flex-col gap-3"> - <h1 className="text-2xl md:text-3xl font-bold tracking-tight"> - Your Projects - </h1> - <p className="text-muted-foreground"> - {savedProjects.length}{" "} - {savedProjects.length === 1 ? "project" : "projects"} - {isSelectionMode && selectedProjects.size > 0 && ( - <span className="ml-2 text-primary"> - • {selectedProjects.size} selected - </span> - )} - </p> - </div> - <div className="hidden md:block"> - {isSelectionMode ? ( - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={handleCancelSelection}> - <X className="!size-4" /> - Cancel - </Button> - {selectedProjects.size > 0 && ( - <Button - variant="destructive" - onClick={() => setIsBulkDeleteDialogOpen(true)} - > - <Trash2 className="!size-4" /> - Delete Selected ({selectedProjects.size}) - </Button> - )} - </div> - ) : ( - <div className="flex items-center gap-2"> - <Button - variant="outline" - onClick={() => setIsSelectionMode(true)} - disabled={savedProjects.length === 0} - > - Select Projects - </Button> - <CreateButton onClick={handleCreateProject} /> - </div> - )} - </div> - </div> + return ( + <div className="min-h-screen bg-background"> + <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> + <Link + href="/" + className="flex items-center gap-1 hover:text-muted-foreground transition-colors" + > + <ChevronLeft className="!size-5 shrink-0" /> + <span className="text-sm font-medium">Back</span> + </Link> + <div className="block md:hidden"> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleCancelSelection} + > + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <CreateButton onClick={handleCreateProject} /> + )} + </div> + </div> + <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> + <div className="mb-8 flex items-center justify-between"> + <div className="flex flex-col gap-3"> + <h1 className="text-2xl md:text-3xl font-bold tracking-tight"> + Your Projects + </h1> + <p className="text-muted-foreground"> + {savedProjects.length}{" "} + {savedProjects.length === 1 ? "project" : "projects"} + {isSelectionMode && selectedProjects.size > 0 && ( + <span className="ml-2 text-primary"> + • {selectedProjects.size} selected + </span> + )} + </p> + </div> + <div className="hidden md:block"> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={handleCancelSelection}> + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete Selected ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => setIsSelectionMode(true)} + disabled={savedProjects.length === 0} + > + Select Projects + </Button> + <CreateButton onClick={handleCreateProject} /> + </div> + )} + </div> + </div> - <div className="mb-4 flex items-center justify-between gap-4"> - <div className="flex-1 max-w-72"> - <Input - placeholder="Search projects..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - </div> - <Select value={sortOption} onValueChange={setSortOption}> - <SelectTrigger className="w-[180px]"> - <SelectValue placeholder="Sort by" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem> - <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem> - <SelectItem value="name-asc">Name (A-Z)</SelectItem> - <SelectItem value="name-desc">Name (Z-A)</SelectItem> - </SelectContent> - </Select> - </div> + <div className="mb-4 flex items-center justify-between gap-4"> + <div className="flex-1 max-w-72"> + <Input + placeholder="Search projects..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + </div> + <Select value={sortOption} onValueChange={setSortOption}> + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Sort by" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem> + <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem> + <SelectItem value="name-asc">Name (A-Z)</SelectItem> + <SelectItem value="name-desc">Name (Z-A)</SelectItem> + </SelectContent> + </Select> + </div> - {isSelectionMode && sortedProjects.length > 0 && ( - <div className="mb-6 p-4 bg-muted/30 rounded-lg border"> - <div className="flex items-center gap-3"> - <Checkbox - checked={someSelected ? "indeterminate" : allSelected} - onCheckedChange={(value) => handleSelectAll(!!value)} - /> - <span className="text-sm font-medium"> - {allSelected ? "Deselect All" : "Select All"} - </span> - <span className="text-sm text-muted-foreground"> - ({selectedProjects.size} of {sortedProjects.length} selected) - </span> - </div> - </div> - )} + {isSelectionMode && sortedProjects.length > 0 && ( + <div className="mb-6 p-4 bg-muted/30 rounded-lg border"> + <div className="flex items-center gap-3"> + <Checkbox + checked={someSelected ? "indeterminate" : allSelected} + onCheckedChange={(value) => handleSelectAll(!!value)} + /> + <span className="text-sm font-medium"> + {allSelected ? "Deselect All" : "Select All"} + </span> + <span className="text-sm text-muted-foreground"> + ({selectedProjects.size} of {sortedProjects.length} selected) + </span> + </div> + </div> + )} - {isLoading || !isInitialized ? ( - <div className="flex items-center justify-center py-16"> - <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> - </div> - ) : savedProjects.length === 0 ? ( - <NoProjects onCreateProject={handleCreateProject} /> - ) : sortedProjects.length === 0 ? ( - <NoResults - searchQuery={searchQuery} - onClearSearch={() => setSearchQuery("")} - /> - ) : ( - <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> - {sortedProjects.map((project) => ( - <ProjectCard - key={project.id} - project={project} - isSelectionMode={isSelectionMode} - isSelected={selectedProjects.has(project.id)} - onSelect={handleSelectProject} - /> - ))} - </div> - )} - </main> + {isLoading || !isInitialized ? ( + <div className="flex items-center justify-center py-16"> + <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> + </div> + ) : savedProjects.length === 0 ? ( + <NoProjects onCreateProject={handleCreateProject} /> + ) : sortedProjects.length === 0 ? ( + <NoResults + searchQuery={searchQuery} + onClearSearch={() => setSearchQuery("")} + /> + ) : ( + <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> + {sortedProjects.map((project) => ( + <ProjectCard + key={project.id} + project={project} + isSelectionMode={isSelectionMode} + isSelected={selectedProjects.has(project.id)} + onSelect={handleSelectProject} + /> + ))} + </div> + )} + </main> - <DeleteProjectDialog - isOpen={isBulkDeleteDialogOpen} - onOpenChange={setIsBulkDeleteDialogOpen} - onConfirm={handleBulkDelete} - /> - </div> - ); + <DeleteProjectDialog + isOpen={isBulkDeleteDialogOpen} + onOpenChange={setIsBulkDeleteDialogOpen} + onConfirm={handleBulkDelete} + /> + </div> + ); } interface ProjectCardProps { - project: TProject; - isSelectionMode?: boolean; - isSelected?: boolean; - onSelect?: (projectId: string, checked: boolean) => void; + project: TProject; + isSelectionMode?: boolean; + isSelected?: boolean; + onSelect?: (projectId: string, checked: boolean) => void; } function ProjectCard({ - project, - isSelectionMode = false, - isSelected = false, - onSelect, + project, + isSelectionMode = false, + isSelected = false, + onSelect, }: ProjectCardProps) { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const { deleteProject, renameProject, duplicateProject } = useProjectStore(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const { deleteProject, renameProject, duplicateProject } = useProjectStore(); - const formatDate = (date: Date): string => { - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - }; + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; - const handleDeleteProject = async () => { - await deleteProject(project.id); - setIsDropdownOpen(false); - }; + const handleDeleteProject = async () => { + await deleteProject(project.id); + setIsDropdownOpen(false); + }; - const handleRenameProject = async (newName: string) => { - await renameProject(project.id, newName); - setIsRenameDialogOpen(false); - }; + const handleRenameProject = async (newName: string) => { + await renameProject(project.id, newName); + setIsRenameDialogOpen(false); + }; - const handleDuplicateProject = async () => { - setIsDropdownOpen(false); - await duplicateProject(project.id); - }; + const handleDuplicateProject = async () => { + setIsDropdownOpen(false); + await duplicateProject(project.id); + }; - const handleCardClick = (e: React.MouseEvent) => { - if (isSelectionMode) { - e.preventDefault(); - onSelect?.(project.id, !isSelected); - } - }; + const handleCardClick = (e: React.MouseEvent) => { + if (isSelectionMode) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; - return ( - <> - {isSelectionMode ? ( - <div onClick={handleCardClick} className="block group cursor-pointer"> - <Card - className={`overflow-hidden bg-background border-none p-0 transition-all ${ - isSelectionMode && isSelected ? "ring-2 ring-primary" : "" - }`} - > - <div - className={`relative aspect-square bg-muted transition-opacity ${ - isDropdownOpen - ? "opacity-65" - : "opacity-100 group-hover:opacity-65" - }`} - > - {/* Selection checkbox */} - {isSelectionMode && ( - <div className="absolute top-3 left-3 z-10"> - <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => - onSelect?.(project.id, checked as boolean) - } - onClick={(e) => e.stopPropagation()} - className="w-4 h-4" - /> - </div> - </div> - )} + return ( + <> + {isSelectionMode ? ( + <div onClick={handleCardClick} className="block group cursor-pointer"> + <Card + className={`overflow-hidden bg-background border-none p-0 transition-all ${ + isSelectionMode && isSelected ? "ring-2 ring-primary" : "" + }`} + > + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen + ? "opacity-65" + : "opacity-100 group-hover:opacity-65" + }`} + > + {/* Selection checkbox */} + {isSelectionMode && ( + <div className="absolute top-3 left-3 z-10"> + <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => + onSelect?.(project.id, checked as boolean) + } + onClick={(e) => e.stopPropagation()} + className="w-4 h-4" + /> + </div> + </div> + )} - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - alt="Project thumbnail" - fill - className="object-cover" - /> - ) : ( - <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> - </div> + {/* Thumbnail preview or placeholder */} + <div className="absolute inset-0"> + {project.thumbnail ? ( + <Image + src={project.thumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> + </div> - <CardContent className="px-0 pt-5 flex flex-col gap-1"> - <div className="flex items-start justify-between"> - <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> - {project.name} - </h3> - {!isSelectionMode && ( - <DropdownMenu - open={isDropdownOpen} - onOpenChange={setIsDropdownOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="sm" - className={`size-6 p-0 transition-all shrink-0 ml-2 ${ - isDropdownOpen - ? "opacity-100" - : "opacity-0 group-hover:opacity-100" - }`} - onClick={(e) => e.preventDefault()} - > - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - </DropdownMenuItem> - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); - }} - > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - </div> + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + {!isSelectionMode && ( + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + > + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + </div> - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> - <span>Created {formatDate(project.createdAt)}</span> - </div> - </div> - </CardContent> - </Card> - </div> - ) : ( - <Link href={`/editor/${project.id}`} className="block group"> - <Card - className={`overflow-hidden bg-background border-none p-0 transition-all ${ - isSelectionMode && isSelected ? "ring-2 ring-primary" : "" - }`} - > - <div - className={`relative aspect-square bg-muted transition-opacity ${ - isDropdownOpen - ? "opacity-65" - : "opacity-100 group-hover:opacity-65" - }`} - > - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - alt="Project thumbnail" - fill - className="object-cover" - /> - ) : ( - <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> - </div> + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> + </div> + </CardContent> + </Card> + </div> + ) : ( + <Link href={`/editor/${project.id}`} className="block group"> + <Card + className={`overflow-hidden bg-background border-none p-0 transition-all ${ + isSelectionMode && isSelected ? "ring-2 ring-primary" : "" + }`} + > + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen + ? "opacity-65" + : "opacity-100 group-hover:opacity-65" + }`} + > + {/* Thumbnail preview or placeholder */} + <div className="absolute inset-0"> + {project.thumbnail ? ( + <Image + src={project.thumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> + </div> - <CardContent className="px-0 pt-5 flex flex-col gap-1"> - <div className="flex items-start justify-between"> - <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> - {project.name} - </h3> - <DropdownMenu - open={isDropdownOpen} - onOpenChange={setIsDropdownOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="sm" - className={`size-6 p-0 transition-all shrink-0 ml-2 ${ - isDropdownOpen - ? "opacity-100" - : "opacity-0 group-hover:opacity-100" - }`} - onClick={(e) => e.preventDefault()} - > - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - </DropdownMenuItem> - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); - }} - > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + > + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> - <span>Created {formatDate(project.createdAt)}</span> - </div> - </div> - </CardContent> - </Card> - </Link> - )} - <DeleteProjectDialog - isOpen={isDeleteDialogOpen} - onOpenChange={setIsDeleteDialogOpen} - onConfirm={handleDeleteProject} - projectName={project.name} - /> - <RenameProjectDialog - isOpen={isRenameDialogOpen} - onOpenChange={setIsRenameDialogOpen} - onConfirm={handleRenameProject} - projectName={project.name} - /> - </> - ); + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> + </div> + </CardContent> + </Card> + </Link> + )} + <DeleteProjectDialog + isOpen={isDeleteDialogOpen} + onOpenChange={setIsDeleteDialogOpen} + onConfirm={handleDeleteProject} + projectName={project.name} + /> + <RenameProjectDialog + isOpen={isRenameDialogOpen} + onOpenChange={setIsRenameDialogOpen} + onConfirm={handleRenameProject} + projectName={project.name} + /> + </> + ); } function CreateButton({ onClick }: { onClick?: () => void }) { - return ( - <Button className="flex" onClick={onClick}> - <Plus className="!size-4" /> - <span className="text-sm font-medium">New project</span> - </Button> - ); + return ( + <Button className="flex" onClick={onClick}> + <Plus className="!size-4" /> + <span className="text-sm font-medium">New project</span> + </Button> + ); } function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-16 text-center"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> - <Video className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="text-lg font-medium mb-2">No projects yet</h3> - <p className="text-muted-foreground mb-6 max-w-md"> - Start creating your first video project. Import media, edit, and export - professional videos. - </p> - <Button size="lg" className="gap-2" onClick={onCreateProject}> - <Plus className="h-4 w-4" /> - Create Your First Project - </Button> - </div> - ); + return ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> + <Video className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-lg font-medium mb-2">No projects yet</h3> + <p className="text-muted-foreground mb-6 max-w-md"> + Start creating your first video project. Import media, edit, and export + professional videos. + </p> + <Button size="lg" className="gap-2" onClick={onCreateProject}> + <Plus className="h-4 w-4" /> + Create Your First Project + </Button> + </div> + ); } function NoResults({ - searchQuery, - onClearSearch, + searchQuery, + onClearSearch, }: { - searchQuery: string; - onClearSearch: () => void; + searchQuery: string; + onClearSearch: () => void; }) { - return ( - <div className="flex flex-col items-center justify-center py-16 text-center"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> - <Search className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="text-lg font-medium mb-2">No results found</h3> - <p className="text-muted-foreground mb-6 max-w-md"> - Your search for "{searchQuery}" did not return any results. - </p> - <Button onClick={onClearSearch} variant="outline"> - Clear Search - </Button> - </div> - ); + return ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> + <Search className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-lg font-medium mb-2">No results found</h3> + <p className="text-muted-foreground mb-6 max-w-md"> + Your search for "{searchQuery}" did not return any results. + </p> + <Button onClick={onClearSearch} variant="outline"> + Clear Search + </Button> + </div> + ); } diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index a3b98908..c62dce3a 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/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 34d75833..c56e8fc1 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 0e54ce0c..56b3d27a 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>
+ );
+}
|
