summaryrefslogtreecommitdiff
path: root/apps/web/src/app
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/app
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/app')
-rw-r--r--apps/web/src/app/(auth)/login/page.tsx146
-rw-r--r--apps/web/src/app/(auth)/signup/page.tsx162
-rw-r--r--apps/web/src/app/animation/page.tsx24
-rw-r--r--apps/web/src/app/api/auth/[...all]/route.ts2
-rw-r--r--apps/web/src/app/api/get-upload-url/route.ts128
-rw-r--r--apps/web/src/app/api/health/route.ts6
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts467
-rw-r--r--apps/web/src/app/api/transcribe/route.ts189
-rw-r--r--apps/web/src/app/api/waitlist/export/route.ts83
-rw-r--r--apps/web/src/app/base-page.tsx53
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx246
-rw-r--r--apps/web/src/app/blog/page.tsx151
-rw-r--r--apps/web/src/app/contributors/page.tsx518
-rw-r--r--apps/web/src/app/editor/[project_id]/layout.tsx9
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx527
-rw-r--r--apps/web/src/app/globals.css394
-rw-r--r--apps/web/src/app/layout.tsx86
-rw-r--r--apps/web/src/app/metadata.ts164
-rw-r--r--apps/web/src/app/page.tsx22
-rw-r--r--apps/web/src/app/privacy/page.tsx512
-rw-r--r--apps/web/src/app/projects/page.tsx1498
-rw-r--r--apps/web/src/app/projects/store.ts129
-rw-r--r--apps/web/src/app/roadmap/page.tsx371
-rw-r--r--apps/web/src/app/robots.ts18
-rw-r--r--apps/web/src/app/rss.xml/route.ts78
-rw-r--r--apps/web/src/app/sitemap.ts110
-rw-r--r--apps/web/src/app/sponsors/page.tsx82
-rw-r--r--apps/web/src/app/terms/page.tsx580
-rw-r--r--apps/web/src/app/why-not-capcut/page.tsx191
29 files changed, 3033 insertions, 3913 deletions
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
deleted file mode 100644
index f28cd191..00000000
--- a/apps/web/src/app/(auth)/login/page.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { memo, Suspense } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import Link from "next/link";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { ArrowLeft, Loader2 } from "lucide-react";
-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();
-
- 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>
- );
-};
-
-export default memo(LoginPage);
diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx
deleted file mode 100644
index c3a0f899..00000000
--- a/apps/web/src/app/(auth)/signup/page.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { memo, Suspense } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import Link from "next/link";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { ArrowLeft, Loader2 } from "lucide-react";
-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();
-
- 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/animation/page.tsx b/apps/web/src/app/animation/page.tsx
deleted file mode 100644
index 37be1ef0..00000000
--- a/apps/web/src/app/animation/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { useState } from "react";
-
-export default function AnimationPage() {
- const [isExpanded, setIsExpanded] = useState(false);
-
- return (
- <div className="h-dvh flex">
- <div className="bg-panel p-6 w-[20rem]">
- <InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
- </div>
- <div className="p-6 flex items-end justify-end">
- <p
- onClick={() => setIsExpanded(!isExpanded)}
- className="cursor-pointer hover:opacity-75 transition-opacity"
- >
- {isExpanded ? "Collapse" : "Expand"}
- </p>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts
index 5deef204..e17d0cce 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 { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts
deleted file mode 100644
index dc5b7328..00000000
--- a/apps/web/src/app/api/get-upload-url/route.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { AwsClient } from "aws4fetch";
-import { nanoid } from "nanoid";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { isTranscriptionConfigured } from "@/lib/transcription-utils";
-
-const uploadRequestSchema = z.object({
- fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
- errorMap: () => ({
- message: "File extension must be wav, mp3, m4a, or flac",
- }),
- }),
-});
-
-const apiResponseSchema = z.object({
- uploadUrl: z.string().url(),
- fileName: z.string().min(1),
-});
-
-export async function POST(request: NextRequest) {
- try {
- // Rate limiting
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
-
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- // Check transcription configuration
- const transcriptionCheck = isTranscriptionConfigured();
- if (!transcriptionCheck.configured) {
- console.error(
- "Missing environment variables:",
- JSON.stringify(transcriptionCheck.missingVars)
- );
-
- return NextResponse.json(
- {
- error: "Transcription not configured",
- message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
- },
- { status: 503 }
- );
- }
-
- // Parse and validate request body
- const rawBody = await request.json().catch(() => null);
- if (!rawBody) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const validationResult = uploadRequestSchema.safeParse(rawBody);
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { fileExtension } = validationResult.data;
-
- // Initialize R2 client
- const client = new AwsClient({
- accessKeyId: env.R2_ACCESS_KEY_ID,
- secretAccessKey: env.R2_SECRET_ACCESS_KEY,
- });
-
- // Generate unique filename with timestamp
- const timestamp = Date.now();
- const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
-
- // Create presigned URL
- const url = new URL(
- `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
- );
-
- url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
-
- const signed = await client.sign(new Request(url, { method: "PUT" }), {
- aws: { signQuery: true },
- });
-
- if (!signed.url) {
- throw new Error("Failed to generate presigned URL");
- }
-
- // Prepare and validate response
- const responseData = {
- uploadUrl: signed.url,
- fileName,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error generating upload URL:", error);
- return NextResponse.json(
- {
- error: "Failed to generate upload URL",
- message:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred",
- },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
index ecb9ce04..edb40a0d 100644
--- a/apps/web/src/app/api/health/route.ts
+++ b/apps/web/src/app/api/health/route.ts
@@ -1,5 +1,3 @@
-import { NextRequest } from "next/server";
-
-export async function GET(request: NextRequest) {
- return new Response("OK", { status: 200 });
+export async function GET() {
+ return new Response("OK", { status: 200 });
}
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
index c89bc76c..3070d152 100644
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -1,265 +1,280 @@
-import { NextRequest, NextResponse } from "next/server";
+import { webEnv } from "@opencut/env/web";
+import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
+import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+ commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
});
-export async function GET(request: NextRequest) {
- try {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
+function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
+ if (!query) return `${sort}_desc`;
+ return sort === "score" ? "score" : `${sort}_desc`;
+}
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
+function applyEffectsFilters({
+ params,
+ min_rating,
+ commercial_only,
+}: {
+ params: URLSearchParams;
+ min_rating: number;
+ commercial_only: boolean;
+}) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
- const { searchParams } = new URL(request.url);
+ if (commercial_only) {
+ params.append(
+ "filter",
+ 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
+ );
+ }
+
+ params.append(
+ "filter",
+ "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
+ );
+}
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
+function transformFreesoundResult(
+ result: z.infer<typeof freesoundResultSchema>,
+) {
+ return {
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
+ const { searchParams } = new URL(request.url);
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 }
- );
- }
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 },
+ );
+ }
- const baseUrl = "https://freesound.org/apiv2/search/text/";
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
- // Use score sorting for search queries, downloads for top sounds
- const sortParam = query
- ? sort === "score"
- ? "score"
- : `${sort}_desc`
- : `${sort}_desc`;
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 },
+ );
+ }
- const params = new URLSearchParams({
- query: query || "",
- token: env.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
- });
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
- // Always apply sound effect filters (since we're primarily a sound effects search)
- if (type === "effects" || !type) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
+ const sortParam = buildSortParameter({ query, sort });
- // Filter by license if commercial_only is true
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")'
- );
- }
+ const params = new URLSearchParams({
+ query: query || "",
+ token: webEnv.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
- params.append(
- "filter",
- "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion"
- );
- }
+ const isEffectsSearch = type === "effects" || !type;
+ if (isEffectsSearch) {
+ applyEffectsFilters({ params, min_rating, commercial_only });
+ }
- const response = await fetch(`${baseUrl}?${params.toString()}`);
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status }
- );
- }
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status },
+ );
+ }
- const rawData = await response.json();
+ const rawData = await response.json();
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 }
- );
- }
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 },
+ );
+ }
- const data = freesoundValidation.data;
+ const data = freesoundValidation.data;
- const transformedResults = data.results.map((result) => ({
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- }));
+ const transformedResults = data.results.map(transformFreesoundResult);
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 },
+ );
+ }
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
- }
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
}
diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts
deleted file mode 100644
index 9a497f65..00000000
--- a/apps/web/src/app/api/transcribe/route.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { isTranscriptionConfigured } from "@/lib/transcription-utils";
-
-const transcribeRequestSchema = z.object({
- filename: z.string().min(1, "Filename is required"),
- language: z.string().optional().default("auto"),
- decryptionKey: z.string().min(1, "Decryption key is required").optional(),
- iv: z.string().min(1, "IV is required").optional(),
-});
-
-const modalResponseSchema = z.object({
- text: z.string(),
- segments: z.array(
- z.object({
- id: z.number(),
- seek: z.number(),
- start: z.number(),
- end: z.number(),
- text: z.string(),
- tokens: z.array(z.number()),
- temperature: z.number(),
- avg_logprob: z.number(),
- compression_ratio: z.number(),
- no_speech_prob: z.number(),
- })
- ),
- language: z.string(),
-});
-
-const apiResponseSchema = z.object({
- text: z.string(),
- segments: z.array(
- z.object({
- id: z.number(),
- seek: z.number(),
- start: z.number(),
- end: z.number(),
- text: z.string(),
- tokens: z.array(z.number()),
- temperature: z.number(),
- avg_logprob: z.number(),
- compression_ratio: z.number(),
- no_speech_prob: z.number(),
- })
- ),
- language: z.string(),
-});
-
-export async function POST(request: NextRequest) {
- try {
- // Rate limiting
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- const origin = request.headers.get("origin");
-
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- // Check transcription configuration
- const transcriptionCheck = isTranscriptionConfigured();
- if (!transcriptionCheck.configured) {
- console.error(
- "Missing environment variables:",
- JSON.stringify(transcriptionCheck.missingVars)
- );
-
- return NextResponse.json(
- {
- error: "Transcription not configured",
- message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
- },
- { status: 503 }
- );
- }
-
- // Parse and validate request body
- const rawBody = await request.json().catch(() => null);
- if (!rawBody) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const validationResult = transcribeRequestSchema.safeParse(rawBody);
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { filename, language, decryptionKey, iv } = validationResult.data;
-
- // Prepare request body for Modal
- const modalRequestBody: any = {
- filename,
- language,
- };
-
- // Add encryption parameters if provided (zero-knowledge)
- if (decryptionKey && iv) {
- modalRequestBody.decryptionKey = decryptionKey;
- modalRequestBody.iv = iv;
- }
-
- // Call Modal transcription service
- const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(modalRequestBody),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Modal API error:", response.status, errorText);
-
- let errorMessage = "Transcription service unavailable";
- try {
- const errorData = JSON.parse(errorText);
- errorMessage = errorData.error || errorMessage;
- } catch {
- // Use default message if parsing fails
- }
-
- return NextResponse.json(
- {
- error: errorMessage,
- message: "Failed to process transcription request",
- },
- { status: response.status >= 500 ? 502 : response.status }
- );
- }
-
- const rawResult = await response.json();
- console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
-
- // Validate Modal response
- const modalValidation = modalResponseSchema.safeParse(rawResult);
- if (!modalValidation.success) {
- console.error("Invalid Modal API response:", modalValidation.error);
- return NextResponse.json(
- { error: "Invalid response from transcription service" },
- { status: 502 }
- );
- }
-
- const result = modalValidation.data;
-
- // Prepare and validate API response
- const responseData = {
- text: result.text,
- segments: result.segments,
- language: result.language,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Transcription API error:", error);
- return NextResponse.json(
- {
- error: "Internal server error",
- message: "An unexpected error occurred during transcription",
- },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts
deleted file mode 100644
index 0200e255..00000000
--- a/apps/web/src/app/api/waitlist/export/route.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { db, exportWaitlist, eq } from "@opencut/db";
-import { randomUUID } from "crypto";
-import {
- exportWaitlistSchema,
- exportWaitlistResponseSchema,
-} from "@/lib/schemas/waitlist";
-
-const requestSchema = exportWaitlistSchema;
-const responseSchema = exportWaitlistResponseSchema;
-
-export async function POST(request: NextRequest) {
- try {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const body = await request.json().catch(() => null);
- if (!body) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const parsed = requestSchema.safeParse(body);
- if (!parsed.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: parsed.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { email } = parsed.data;
-
- const existing = await db
- .select({ id: exportWaitlist.id })
- .from(exportWaitlist)
- .where(eq(exportWaitlist.email, email))
- .limit(1);
-
- if (existing.length > 0) {
- const responseData = { success: true, alreadySubscribed: true } as const;
- const validated = responseSchema.safeParse(responseData);
- if (!validated.success) {
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
- return NextResponse.json(validated.data);
- }
-
- await db.insert(exportWaitlist).values({
- id: randomUUID(),
- email,
- createdAt: new Date(),
- updatedAt: new Date(),
- });
-
- const responseData = { success: true } as const;
- const validated = responseSchema.safeParse(responseData);
- if (!validated.success) {
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
- return NextResponse.json(validated.data);
- } catch (error) {
- console.error("Waitlist API error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx
new file mode 100644
index 00000000..a3d56fe5
--- /dev/null
+++ b/apps/web/src/app/base-page.tsx
@@ -0,0 +1,53 @@
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { cn } from "@/utils/ui";
+
+interface BasePageProps {
+ children: React.ReactNode;
+ className?: string;
+ mainClassName?: string;
+ maxWidth?: "3xl" | "6xl" | "full";
+ title?: string;
+ description?: string;
+}
+
+export function BasePage({
+ children,
+ className = "",
+ mainClassName = "",
+ maxWidth = "3xl",
+ title,
+ description,
+}: BasePageProps) {
+ const maxWidthClass = {
+ "3xl": "max-w-3xl",
+ "6xl": "max-w-6xl",
+ full: "max-w-full",
+ }[maxWidth];
+
+ return (
+ <section className={cn("bg-background min-h-screen", className)}>
+ <Header />
+ <main
+ className={cn(
+ "relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
+ maxWidthClass,
+ mainClassName,
+ )}
+ >
+ {title && description && (
+ <div className="flex flex-col gap-8 text-center">
+ <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
+ {title}
+ </h1>
+ <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
+ {description}
+ </p>
+ </div>
+ )}
+ {children}
+ </main>
+ <Footer />
+ </section>
+ );
+}
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index d759050b..46c6163d 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -1,144 +1,152 @@
-import { Header } from "@/components/header";
-import Prose from "@/components/ui/prose";
-import { Separator } from "@/components/ui/separator";
-import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
-import { Metadata } from "next";
+import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
+import { BasePage } from "@/app/base-page";
+import Prose from "@/components/ui/prose";
+import { Separator } from "@/components/ui/separator";
+import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
+import type { Author, Post } from "@/types/blog";
type PageProps = {
- params: Promise<{ slug: string }>;
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export async function generateMetadata({
- params,
+ params,
}: PageProps): Promise<Metadata> {
- const slug = (await params).slug;
+ const slug = (await params).slug;
- const data = await getSinglePost(slug);
+ const data = await getSinglePost({ slug });
- if (!data || !data.post) return {};
+ if (!data || !data.post) return {};
- return {
- title: data.post.title,
- description: data.post.description,
- twitter: {
- title: `${data.post.title}`,
- description: `${data.post.description}`,
- card: "summary_large_image",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- },
- openGraph: {
- type: "article",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- title: data.post.title,
- description: data.post.description,
- publishedTime: new Date(data.post.publishedAt).toISOString(),
- authors: [
- ...data.post.authors.map((author: { name: string }) => author.name),
- ],
- },
- };
+ return {
+ title: data.post.title,
+ description: data.post.description,
+ twitter: {
+ title: `${data.post.title}`,
+ description: `${data.post.description}`,
+ card: "summary_large_image",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ },
+ openGraph: {
+ type: "article",
+ images: [
+ {
+ url: data.post.coverImage,
+ width: "1200",
+ height: "630",
+ alt: data.post.title,
+ },
+ ],
+ title: data.post.title,
+ description: data.post.description,
+ publishedTime: new Date(data.post.publishedAt).toISOString(),
+ authors: data.post.authors.map((author: Author) => author.name),
+ },
+ };
}
export async function generateStaticParams() {
- const data = await getPosts();
- if (!data || !data.posts.length) return [];
+ const data = await getPosts();
+ if (!data || !data.posts.length) return [];
- return data.posts.map((post) => ({
- slug: post.slug,
- }));
+ return data.posts.map((post) => ({
+ slug: post.slug,
+ }));
}
-async function Page({ params }: PageProps) {
- const slug = (await params).slug;
- const data = await getSinglePost(slug);
- if (!data || !data.post) return notFound();
+export default async function BlogPostPage({ params }: PageProps) {
+ const slug = (await params).slug;
+ const data = await getSinglePost({ slug });
+ if (!data || !data.post) return notFound();
- const html = await processHtmlContent(data.post.content);
+ const html = await processHtmlContent({ html: data.post.content });
- const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
- "en-US",
- {
- day: "numeric",
- month: "long",
- year: "numeric",
- }
- );
+ return (
+ <BasePage>
+ <PostHeader post={data.post} />
+ <Separator />
+ <PostContent html={html} />
+ </BasePage>
+ );
+}
- return (
- <div className="min-h-screen bg-background">
- <Header />
+function PostHeader({ post }: { post: Post }) {
+ const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
- <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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
- </div>
+ return (
+ <>
+ {post.coverImage && <PostCoverImage post={post} />}
+ <PostMeta date={formattedDate} publishedAt={post.publishedAt} />
+ <PostTitle title={post.title} />
+ <PostAuthor author={post.authors[0]} />
+ </>
+ );
+}
- <div className="relative container max-w-3xl mx-auto px-4 py-16">
- <div className="text-center mb-6">
- {data.post.coverImage && (
- <div className="relative aspect-video rounded-lg overflow-hidden mb-6">
- <Image
- src={data.post.coverImage}
- alt={data.post.title}
- loading="eager"
- fill
- className="object-cover rounded-lg"
- />
- </div>
- )}
- <div className="flex items-center justify-center mb-6">
- <time dateTime={data.post.publishedAt.toString()}>
- {formattedDate}
- </time>
- </div>
+function PostCoverImage({ post }: { post: Post }) {
+ return (
+ <div className="relative aspect-video overflow-hidden rounded-lg">
+ <Image
+ src={post.coverImage}
+ alt={post.title}
+ loading="eager"
+ fill
+ className="rounded-lg object-cover"
+ />
+ </div>
+ );
+}
- <h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
- {data.post.title}
- </h1>
- <div className="flex items-center justify-center gap-2">
- {data.post.authors[0] && (
- <>
- <Image
- src={data.post.authors[0].image}
- alt={data.post.authors[0].name}
- width={36}
- height={36}
- loading="eager"
- className="aspect-square shrink-0 size-8 rounded-full"
- />
- <p className="text-muted-foreground">
- {data.post.authors[0].name}
- </p>
- </>
- )}
- </div>
- </div>
- <Separator />
- <section className="mt-14">
- <Prose html={html} />
- </section>
- </div>
- </main>
- </div>
- );
+function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
+ return (
+ <div className="flex items-center justify-center">
+ <time dateTime={publishedAt.toString()}>{date}</time>
+ </div>
+ );
}
-export default Page;
+function PostTitle({ title }: { title: string }) {
+ return (
+ <h1 className="text-5xl font-bold tracking-tight md:text-4xl">{title}</h1>
+ );
+}
+
+function PostAuthor({ author }: { author?: Author }) {
+ if (!author) return null;
+
+ return (
+ <div className="flex items-center justify-center gap-2">
+ <Image
+ src={author.image}
+ alt={author.name}
+ width={36}
+ height={36}
+ loading="eager"
+ className="aspect-square size-8 shrink-0 rounded-full"
+ />
+ <p className="text-muted-foreground">{author.name}</p>
+ </div>
+ );
+}
+
+function PostContent({ html }: { html: string }) {
+ return (
+ <section className="pt-8">
+ <Prose html={html} />
+ </section>
+ );
+}
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
index 08da9fd7..9e3b6d11 100644
--- a/apps/web/src/app/blog/page.tsx
+++ b/apps/web/src/app/blog/page.tsx
@@ -1,98 +1,73 @@
-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 type { Metadata } from "next";
import Link from "next/link";
-import { getPosts } from "@/lib/blog-query";
-import Image from "next/image";
+import { BasePage } from "@/app/base-page";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { getPosts } from "@/lib/blog/query";
+import type { Author, Post } from "@/types/blog";
export const metadata: Metadata = {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- type: "website",
- },
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Blog - OpenCut",
+ description:
+ "Read the latest news and updates about OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
};
export default async function BlogPage() {
- const data = await getPosts();
- if (!data || !data.posts) return <div>No posts yet</div>;
-
- return (
- <div className="min-h-screen bg-background">
- <Header />
+ const data = await getPosts();
+ if (!data || !data.posts) return <div>No posts yet</div>;
- <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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
- </div>
+ return (
+ <BasePage
+ title="Blog"
+ description="Read the latest news and updates about OpenCut, the free and open-source video editor."
+ >
+ <div className="flex flex-col">
+ {data.posts.map((post) => (
+ <div key={post.id} className="flex flex-col">
+ <BlogPostItem post={post} />
+ <Separator />
+ </div>
+ ))}
+ </div>
+ </BasePage>
+ );
+}
- <div className="relative container max-w-3xl mx-auto px-4 py-16">
- <div className="text-center mb-20">
- <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
- Blog
- </h1>
- <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
- Read the latest news and updates about OpenCut, the free and
- open-source video editor.
- </p>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- {data.posts.map((post) => (
- <Link key={post.id} href={`/blog/${post.slug}`}>
- <Card className="h-full hover:shadow-lg transition-shadow overflow-hidden">
- {post.coverImage && (
- <div className="relative aspect-video">
- <Image
- src={post.coverImage}
- alt={post.title}
- fill
- className="object-cover rounded-xl"
- />
- </div>
- )}
+function BlogPostItem({ post }: { post: Post }) {
+ return (
+ <Link href={`/blog/${post.slug}`}>
+ <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
+ <div className="flex flex-col gap-2">
+ <h2 className="text-xl font-semibold">{post.title}</h2>
+ <p className="text-muted-foreground">{post.description}</p>
+ </div>
+ {post.authors && post.authors.length > 0 && (
+ <AuthorList authors={post.authors} />
+ )}
+ </div>
+ </Link>
+ );
+}
- <CardContent className="p-6">
- {post.authors && post.authors.length > 0 && (
- <div className="flex items-center gap-2 mb-4">
- {post.authors.map((author, index) => (
- <div
- key={author.id}
- className="flex items-center gap-2"
- >
- <Avatar className="w-6 h-6">
- <AvatarImage
- src={author.image}
- alt={author.name}
- />
- <AvatarFallback className="text-xs">
- {author.name.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <span className="text-sm text-muted-foreground">
- {author.name}
- </span>
- {index < post.authors.length - 1 && (
- <span className="text-muted-foreground">•</span>
- )}
- </div>
- ))}
- </div>
- )}
- <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
- <p className="text-muted-foreground">{post.description}</p>
- </CardContent>
- </Card>
- </Link>
- ))}
- </div>
- </div>
- </main>
- </div>
- );
+function AuthorList({ authors }: { authors: Author[] }) {
+ return (
+ <div className="flex items-center gap-2">
+ {authors.map((author) => (
+ <div key={author.id} className="flex items-center gap-2">
+ <Avatar className="size-6 shadow-sm">
+ <AvatarImage src={author.image} alt={author.name} />
+ <AvatarFallback className="text-xs">
+ {author.name.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </div>
+ ))}
+ </div>
+ );
}
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index 3ad99023..21703852 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -1,330 +1,242 @@
-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 type { Metadata } from "next";
import Link from "next/link";
-import {
- GithubIcon,
- MarbleIcon,
- VercelIcon,
- DataBuddyIcon,
-} from "@/components/icons";
-import { Badge } from "@/components/ui/badge";
-import { EXTERNAL_TOOLS } from "@/constants/site";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Card, CardContent } from "@/components/ui/card";
+import { EXTERNAL_TOOLS } from "@/constants/site-constants";
+import { BasePage } from "../base-page";
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",
- },
+ 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;
+ 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
- } as RequestInit
- );
+ 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 [];
- }
+ if (!response.ok) {
+ console.error("Failed to fetch contributors");
+ return [];
+ }
- const contributors = (await response.json()) as Contributor[];
+ const contributors = (await response.json()) as Contributor[];
- const filteredContributors = contributors.filter(
- (contributor: Contributor) => contributor.type === "User"
- );
+ const filteredContributors = contributors.filter(
+ (contributor) => contributor.type === "User",
+ );
- return filteredContributors;
- } catch (error) {
- console.error("Error fetching contributors:", error);
- return [];
- }
+ 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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-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>
+ const contributors = await getContributors();
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
+ const totalContributions = contributors.reduce(
+ (sum, c) => sum + c.contributions,
+ 0,
+ );
- <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>
+ return (
+ <BasePage
+ title="Contributors"
+ description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
+ >
+ <div className="-mt-4 flex items-center justify-center gap-8 text-sm">
+ <StatItem value={contributors.length} label="contributors" />
+ <StatItem value={totalContributions} label="contributions" />
+ </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-linear-to-r from-muted/50 to-muted/30 rounded-2xl blur-sm group-hover:blur-md transition-all duration-300" />
- <Card className="relative bg-background/80 backdrop-blur-xs 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>
- )}
+ <div className="mx-auto flex max-w-6xl flex-col gap-20">
+ {topContributors.length > 0 && (
+ <TopContributorsSection contributors={topContributors} />
+ )}
+ {otherContributors.length > 0 && (
+ <AllContributorsSection contributors={otherContributors} />
+ )}
+ <ExternalToolsSection />
+ <GitHubContributeSection
+ title="Join the community"
+ description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
+ />
+ </div>
+ </BasePage>
+ );
+}
- {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>
+function StatItem({ value, label }: { value: number; label: string }) {
+ return (
+ <div className="flex items-center gap-2">
+ <div className="bg-foreground size-2 rounded-full" />
+ <span className="font-medium">{value}</span>
+ <span className="text-muted-foreground">{label}</span>
+ </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>
- )}
+function TopContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+ <div className="flex flex-col gap-10">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">Top contributors</h2>
+ <p className="text-muted-foreground">
+ Leading the way in contributions
+ </p>
+ </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="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
+ {contributors.map((contributor) => (
+ <TopContributorCard key={contributor.id} contributor={contributor} />
+ ))}
+ </div>
+ </div>
+ );
+}
- <div className="mt-6 mb-20">
- <div className="text-center mb-12">
- <h2 className="text-2xl font-semibold mb-2">External Tools</h2>
- <p className="text-muted-foreground">
- Tools we use to build OpenCut
- </p>
- </div>
+function TopContributorCard({ contributor }: { contributor: Contributor }) {
+ return (
+ <Link
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="w-full"
+ >
+ <Card>
+ <CardContent className="flex flex-col gap-6 p-8 text-center">
+ <Avatar className="mx-auto size-28">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback className="text-lg font-semibold">
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex flex-col gap-2">
+ <h3 className="text-xl font-semibold">{contributor.login}</h3>
+ <div className="flex items-center justify-center gap-2">
+ <span className="font-medium">{contributor.contributions}</span>
+ <span className="text-muted-foreground">contributions</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ );
+}
- <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
- {EXTERNAL_TOOLS.map((tool, index) => {
- const IconComponent = {
- MarbleIcon,
- VercelIcon,
- DataBuddyIcon,
- }[tool.icon];
+function AllContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+ <div className="flex flex-col gap-12">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">All contributors</h2>
+ <p className="text-muted-foreground">
+ Everyone who makes OpenCut better
+ </p>
+ </div>
- return (
- <Link
- key={tool.name}
- href={tool.url}
- target="_blank"
- className="group block"
- style={{
- animationDelay: `${index * 100}ms`,
- }}
- >
- <Card className="h-full bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
- <CardContent className="p-6 text-center h-full flex flex-col">
- <div className="mb-4">
- <div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors">
- <IconComponent className="h-6 w-6" size={24} />
- </div>
- </div>
- <h3 className="font-semibold text-lg mb-2 group-hover:text-foreground/80 transition-colors">
- {tool.name}
- </h3>
- <p className="text-sm text-muted-foreground flex-1">
- {tool.description}
- </p>
- </CardContent>
- </Card>
- </Link>
- );
- })}
- </div>
- </div>
+ <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
+ {contributors.map((contributor) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="opacity-100 hover:opacity-70"
+ >
+ <div className="flex flex-col items-center gap-2 p-2">
+ <Avatar className="size-16">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback>
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="text-center">
+ <h3 className="text-sm font-medium">{contributor.login}</h3>
+ <p className="text-muted-foreground text-xs">
+ {contributor.contributions}
+ </p>
+ </div>
+ </div>
+ </Link>
+ ))}
+ </div>
+ </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>
+function ExternalToolsSection() {
+ return (
+ <div className="flex flex-col gap-10">
+ <div className="flex flex-col gap-2 text-center">
+ <h2 className="text-2xl font-semibold">External tools</h2>
+ <p className="text-muted-foreground">Tools we use to build OpenCut</p>
+ </div>
- <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>
- );
+ <div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
+ {EXTERNAL_TOOLS.map((tool, index) => (
+ <Link
+ key={tool.url}
+ href={tool.url}
+ target="_blank"
+ className="block"
+ style={{ animationDelay: `${index * 100}ms` }}
+ >
+ <Card className="h-full">
+ <CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
+ <tool.icon className="size-8" />
+ <div className="flex flex-1 flex-col gap-2">
+ <h3 className="text-lg font-semibold">{tool.name}</h3>
+ <p className="text-muted-foreground text-sm">
+ {tool.description}
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ ))}
+ </div>
+ </div>
+ );
}
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx
deleted file mode 100644
index c37e78b7..00000000
--- a/apps/web/src/app/editor/[project_id]/layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client";
-
-export default function EditorLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return <div>{children}</div>;
-}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 1b53b6b5..e652e928 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,459 +1,108 @@
"use client";
-import { useEffect, useRef } from "react";
-import { useParams, useRouter } from "next/navigation";
+import { useParams } 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";
+import { AssetsPanel } from "@/components/editor/panels/assets";
+import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline";
-import { PreviewPanel } from "@/components/editor/preview-panel";
+import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
-import { usePanelStore } from "@/stores/panel-store";
-import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/providers/editor-provider";
-import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { Onboarding } from "@/components/editor/onboarding";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { usePanelStore } from "@/stores/panel-store";
export default function Editor() {
- const {
- toolsPanel,
- previewPanel,
- mainContent,
- timeline,
- setToolsPanel,
- setPreviewPanel,
- setMainContent,
- setTimeline,
- propertiesPanel,
- setPropertiesPanel,
- activePreset,
- resetCounter,
- } = usePanelStore();
-
- const {
- activeProject,
- loadProject,
- createNewProject,
- isInvalidProjectId,
- markProjectIdAsInvalid,
- } = useProjectStore();
- const params = useParams();
- const router = useRouter();
- const projectId = params.project_id as string;
- const handledProjectIds = useRef<Set<string>>(new Set());
- const isInitializingRef = useRef<boolean>(false);
-
- usePlaybackControls();
-
- useEffect(() => {
- let isCancelled = false;
-
- const initProject = async () => {
- if (!projectId) {
- return;
- }
-
- // Prevent duplicate initialization
- if (isInitializingRef.current) {
- return;
- }
-
- // Check if project is already loaded
- if (activeProject?.id === projectId) {
- return;
- }
-
- // Check global invalid tracking first (most important for preventing duplicates)
- if (isInvalidProjectId(projectId)) {
- return;
- }
-
- // Check if we've already handled this project ID locally
- if (handledProjectIds.current.has(projectId)) {
- return;
- }
-
- // Mark as initializing to prevent race conditions
- isInitializingRef.current = true;
- handledProjectIds.current.add(projectId);
-
- try {
- await loadProject(projectId);
-
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return;
- }
-
- // Project loaded successfully
- isInitializingRef.current = false;
- } catch (error) {
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return;
- }
-
- // More specific error handling - only create new project for actual "not found" errors
- const isProjectNotFound =
- error instanceof Error &&
- (error.message.includes("not found") ||
- error.message.includes("does not exist") ||
- error.message.includes("Project not found"));
-
- if (isProjectNotFound) {
- // Mark this project ID as invalid globally BEFORE creating project
- markProjectIdAsInvalid(projectId);
-
- try {
- const newProjectId = await createNewProject("Untitled Project");
-
- // Check again if component was unmounted
- if (isCancelled) {
- return;
- }
-
- router.replace(`/editor/${newProjectId}`);
- } catch (createError) {
- console.error("Failed to create new project:", createError);
- }
- } else {
- // For other errors (storage issues, corruption, etc.), don't create new project
- console.error(
- "Project loading failed with recoverable error:",
- error
- );
- // Remove from handled set so user can retry
- handledProjectIds.current.delete(projectId);
- }
-
- isInitializingRef.current = false;
- }
- };
+ const params = useParams();
+ const projectId = params.project_id as string;
- initProject();
-
- // Cleanup function to cancel async operations
- return () => {
- isCancelled = true;
- isInitializingRef.current = false;
- };
- }, [
- projectId,
- loadProject,
- createNewProject,
- router,
- isInvalidProjectId,
- markProjectIdAsInvalid,
- ]);
-
- 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">
- {activePreset === "media" ? (
- <ResizablePanelGroup
- key={`media-${activePreset}-${resetCounter}`}
- direction="horizontal"
- className="h-full w-full gap-[0.18rem] px-3 pb-3"
- >
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={100 - toolsPanel}
- minSize={60}
- className="min-w-0 min-h-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"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem]"
- >
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : activePreset === "inspector" ? (
- <ResizablePanelGroup
- key={`inspector-${activePreset}-${resetCounter}`}
- direction="horizontal"
- className="h-full w-full gap-[0.18rem] px-3 pb-3"
- >
- <ResizablePanel
- defaultSize={100 - propertiesPanel}
- minSize={30}
- onResize={(size) => setPropertiesPanel(100 - size)}
- className="min-w-0 min-h-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"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem]"
- >
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0 min-h-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : activePreset === "vertical-preview" ? (
- <ResizablePanelGroup
- key={`vertical-preview-${activePreset}-${resetCounter}`}
- direction="horizontal"
- className="h-full w-full gap-[0.18rem] px-3 pb-3"
- >
- <ResizablePanel
- defaultSize={100 - previewPanel}
- minSize={30}
- onResize={(size) => setPreviewPanel(100 - size)}
- className="min-w-0 min-h-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"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem]"
- >
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
+ return (
+ <EditorProvider projectId={projectId}>
+ <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
+ <EditorHeader />
+ <div className="min-h-0 min-w-0 flex-1">
+ <EditorLayout />
+ </div>
+ <Onboarding />
+ <MigrationDialog />
+ </div>
+ </EditorProvider>
+ );
+}
- <ResizableHandle withHandle />
+function EditorLayout() {
+ const { panels, setPanel } = usePanelStore();
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0"
- >
- <PreviewPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : (
- <ResizablePanelGroup
- key={`default-${activePreset}-${resetCounter}`}
- 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-3"
- >
- {/* Tools Panel */}
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
+ return (
+ <ResizablePanelGroup
+ direction="vertical"
+ className="size-full gap-[0.18rem]"
+ onLayout={(sizes) => {
+ setPanel("mainContent", sizes[0] ?? panels.mainContent);
+ setPanel("timeline", sizes[1] ?? panels.timeline);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.mainContent}
+ minSize={30}
+ maxSize={85}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="size-full gap-[0.19rem] px-3"
+ onLayout={(sizes) => {
+ setPanel("tools", sizes[0] ?? panels.tools);
+ setPanel("preview", sizes[1] ?? panels.preview);
+ setPanel("properties", sizes[2] ?? panels.properties);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.tools}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0 rounded-sm"
+ >
+ <AssetsPanel />
+ </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>
+ <ResizablePanel
+ defaultSize={panels.preview}
+ minSize={30}
+ className="min-h-0 min-w-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
- <ResizableHandle withHandle />
+ <ResizableHandle withHandle />
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0 rounded-sm"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
+ <ResizablePanel
+ defaultSize={panels.properties}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0 rounded-sm"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
- <ResizableHandle withHandle />
+ <ResizableHandle withHandle />
- {/* Timeline */}
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0 px-3 pb-3"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- )}
- </div>
- <Onboarding />
- </div>
- </EditorProvider>
- );
+ <ResizablePanel
+ defaultSize={panels.timeline}
+ minSize={15}
+ maxSize={70}
+ className="min-h-0 px-3 pb-3"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ );
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index ca78ee26..d0da1f51 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -8,85 +8,88 @@
@plugin "tailwindcss-animate";
:root {
- /* Custom colors - light mode (default) */
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(216, 8%, 86%);
- --card-foreground: hsl(0 0% 2%);
- --popover: hsl(0, 0%, 100%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: hsl(205, 84%, 47%);
- --primary-foreground: hsl(0 0% 91%);
- --secondary: hsl(216, 13%, 92%);
- --secondary-foreground: hsl(0 0% 2%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(216, 13%, 92%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 83%);
- --input: hsl(0 0% 85.1%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --panel-background: hsl(216 13% 92%);
- --panel-accent: hsl(216, 8%, 86%);
-
- /* Radius base */
- --radius: 1rem;
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: hsl(203, 100%, 50%);
+ --primary-hover: hsl(203, 100%, 45%);
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(216 13% 94%);
+ --secondary-foreground: hsl(0 0% 2%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(216, 13%, 88%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 88%);
+ --input: hsl(0 0% 85.1%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --panel-background: hsl(216 13% 94%);
+ --panel-accent: hsl(216, 8%, 88%);
+ --sidebar: hsl(0 0% 98%);
}
.dark {
- /* Custom colors - dark mode */
- --background: hsl(0 0% 4%);
- --foreground: hsl(0 0% 89%);
- --card: hsl(0 0% 14.9%);
- --card-foreground: hsl(0 0% 98%);
- --popover: hsl(0 0% 14.9%);
- --popover-foreground: hsl(0 0% 98%);
- --primary: hsl(205, 84%, 53%);
- --primary-foreground: hsl(0 0% 9%);
- --secondary: hsl(0 0% 14.9%);
- --secondary-foreground: hsl(0 0% 98%);
- --muted: hsl(0 0% 14.9%);
- --muted-foreground: hsl(0 0% 63.9%);
- --accent: hsl(0 0% 14.9%);
- --accent-foreground: hsl(0 0% 98%);
- --destructive: hsl(0 100% 60%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(0 0% 17%);
- --input: hsl(0 0% 14.9%);
- --ring: hsl(0 0% 83.1%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 3.9%);
- --sidebar-foreground: hsl(0 0% 98%);
- --sidebar-primary: hsl(0 0% 98%);
- --sidebar-primary-foreground: hsl(0 0% 9%);
- --sidebar-accent: hsl(0 0% 14.9%);
- --sidebar-accent-foreground: hsl(0 0% 98%);
- --sidebar-border: hsl(0 0% 14.9%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --panel-background: hsl(0 0% 11%);
- --panel-accent: hsl(0 0% 15%);
+ --background: hsl(0 0% 4%);
+ --foreground: hsl(0 0% 89%);
+ --card: hsl(0 0% 4%);
+ --card-foreground: hsl(0 0% 89%);
+ --popover: hsl(0 0% 14.9%);
+ --popover-foreground: hsl(0 0% 98%);
+ --primary: hsl(203, 100%, 50%);
+ --primary-hover: hsl(203, 100%, 45%);
+ --primary-foreground: hsl(0 0% 9%);
+ --secondary: hsl(0 0% 14.9%);
+ --secondary-foreground: hsl(0 0% 98%);
+ --muted: hsl(0 0% 14.9%);
+ --muted-foreground: hsl(0 0% 63.9%);
+ --accent: hsl(0, 0%, 28%);
+ --accent-foreground: hsl(0 0% 98%);
+ --destructive: hsl(0 83%, 55%);
+ --destructive-foreground: hsl(0 0% 98%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0 0% 100%);
+ --border: hsl(0 0% 17%);
+ --input: hsl(0 0% 14.9%);
+ --ring: hsl(0 0% 83.1%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 3.9%);
+ --sidebar-foreground: hsl(0 0% 98%);
+ --sidebar-primary: hsl(0 0% 98%);
+ --sidebar-primary-foreground: hsl(0 0% 9%);
+ --sidebar-accent: hsl(0 0% 14.9%);
+ --sidebar-accent-foreground: hsl(0 0% 98%);
+ --sidebar-border: hsl(0 0% 14.9%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --panel-background: hsl(0 0% 11%);
+ --panel-accent: hsl(0 0% 15%);
+ --sidebar: hsl(240 5.9% 10%);
}
@layer base {
- /*
+ /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@@ -94,152 +97,165 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
}
@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
- /* Font sizes */
- --text-base: 0.95rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs: 0.8rem;
- --text-xs--line-height: calc(1 / 0.8);
+ /* Font sizes */
+ --text-xl: 1.20rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs: 0.75rem;
+ --text-xs--line-height: calc(1 / 0.8);
- /* Border radius */
- --radius-lg: var(--radius);
- --radius-md: calc(var(--radius) - 2px);
- --radius-sm: calc(var(--radius) - 8px);
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary-hover: var(--primary-hover);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
- /* Panel */
- --color-panel: var(--panel-background);
- --color-panel-accent: var(--panel-accent);
+ /* Panel */
+ --color-panel: var(--panel-background);
+ --color-panel-accent: var(--panel-accent);
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
}
@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
}
@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
}
@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 6px;
- height: 8px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 8px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 7e32ea22..2b3d0c30 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,62 +1,58 @@
import { ThemeProvider } from "next-themes";
-import { Analytics } from "@vercel/analytics/react";
import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
-import { StorageProvider } from "../components/storage-provider";
-import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
import { baseMetaData } from "./metadata";
-import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client";
-import { env } from "@/env";
+import { webEnv } from "@opencut/env/web";
+import { Inter } from "next/font/google";
+
+const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData;
const protectedRoutes = [
- {
- path: "/none",
- method: "GET",
- },
- {
- path: "/api/waitlist/export",
- method: "POST",
- },
+ {
+ path: "/none",
+ method: "GET",
+ },
];
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" defaultTheme="dark">
- <TooltipProvider>
- <StorageProvider>
- <ScenesMigrator>{children}</ScenesMigrator>
- </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={`${siteFont.className} font-sans antialiased`}>
+ <ThemeProvider
+ attribute="class"
+ defaultTheme="system"
+ disableTransitionOnChange={true}
+ >
+ <TooltipProvider>
+ <Toaster />
+ <Script
+ src="https://cdn.databuddy.cc/databuddy.js"
+ strategy="afterInteractive"
+ async
+ data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
+ data-disabled={webEnv.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}
+ />
+ {children}
+ </TooltipProvider>
+ </ThemeProvider>
+ </body>
+ </html>
+ );
}
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 0608974d..7c192d2b 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -1,86 +1,86 @@
import type { Metadata } from "next";
-import { SITE_INFO, SITE_URL } from "@/constants/site";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export const baseMetaData: Metadata = {
- metadataBase: new URL(SITE_URL),
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- openGraph: {
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- url: SITE_URL,
- siteName: SITE_INFO.title,
- locale: "en_US",
- type: "website",
- images: [
- {
- url: SITE_INFO.openGraphImage,
- width: 1200,
- height: 630,
- alt: "OpenCut Wordmark",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- creator: "@opencutapp",
- images: [SITE_INFO.twitterImage],
- },
- pinterest: {
- richPin: false,
- },
- 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: SITE_INFO.title,
- },
- manifest: "/manifest.json",
- other: {
- "msapplication-config": "/browserconfig.xml",
- },
+ metadataBase: new URL(SITE_URL),
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ openGraph: {
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ url: SITE_URL,
+ siteName: SITE_INFO.title,
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: SITE_INFO.openGraphImage,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Wordmark",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ creator: "@opencutapp",
+ images: [SITE_INFO.twitterImage],
+ },
+ pinterest: {
+ richPin: false,
+ },
+ 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: SITE_INFO.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 818884ed..d97c9a54 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -2,20 +2,20 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import type { Metadata } from "next";
-import { SITE_URL } from "@/constants/site";
+import { SITE_URL } from "@/constants/site-constants";
export const metadata: Metadata = {
- alternates: {
- canonical: SITE_URL,
- },
+ alternates: {
+ canonical: SITE_URL,
+ },
};
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 4bb1e023..b71b8d30 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -1,253 +1,287 @@
-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";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
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",
- },
+ 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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-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-xs 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>
+ return (
+ <BasePage
+ title="Privacy policy"
+ description="Learn how we handle your data and privacy. Contact us if you have any questions."
+ >
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem
+ value="quick-summary"
+ className="rounded-2xl border px-5"
+ >
+ <AccordionTrigger className="no-underline!">
+ Quick summary
+ </AccordionTrigger>
+ <AccordionContent>
+ <h3 className="mb-3 text-lg font-medium">
+ Your content stays private and encrypted.
+ </h3>
+ <ol className="list-decimal space-y-2 pl-6">
+ <li>
+ Basic editing happens locally in your browser - we never see
+ your files
+ </li>
+ <li>
+ AI features require encrypted uploads - your content is
+ encrypted before leaving your device
+ </li>
+ <li>
+ We only collect your email and basic profile info for your
+ account
+ </li>
+ <li>Project data stays on your device, not our servers</li>
+ <li>
+ We use analytics to improve the app, but no personal video
+ content is tracked
+ </li>
+ <li>
+ You can delete your account anytime and all data gets removed
+ </li>
+ <li>We don't sell your data or share it with advertisers</li>
+ </ol>
+ <p className="mt-4">
+ Questions? Email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ </p>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
- <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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
+ <p>
+ <strong>Basic video editing happens locally on your device.</strong>{" "}
+ For standard editing features, we never upload, store, or have access
+ to your video files. Your content remains completely private and under
+ your control.
+ </p>
+ <p>
+ <strong>AI features require secure processing:</strong> When you
+ choose to use AI features like auto captions, your audio/video content
+ is encrypted on your device before being uploaded to our servers for
+ processing. We use zero-knowledge encryption, meaning we cannot
+ decrypt or view your content.
+ </p>
+ <p>
+ After AI processing is complete, the encrypted content is immediately
+ deleted from our servers. Only the results (like generated captions)
+ are returned to your device.
+ </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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Account Information</h2>
+ <p>When you create an account, we only collect:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <li>Email address (for account access)</li>
+ <li>
+ Profile information from Google OAuth (if you choose to sign in with
+ Google)
+ </li>
+ </ul>
+ <p>
+ <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">
- 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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
+ <p>
+ When you use AI-powered features (like auto captions, content
+ analysis, or enhancement tools), your content needs to be processed on
+ our servers. Here's how we protect your privacy:
+ </p>
+ <ul className="list-disc space-y-2 pl-6">
+ <li>
+ <strong>Client-side encryption:</strong> Your content is encrypted
+ on your device before upload
+ </li>
+ <li>
+ <strong>Zero-knowledge processing:</strong> We cannot decrypt or
+ view your original content
+ </li>
+ <li>
+ <strong>Temporary processing:</strong> Encrypted content is deleted
+ immediately after processing
+ </li>
+ <li>
+ <strong>Opt-in only:</strong> AI features are optional - basic
+ editing remains fully local
+ </li>
+ </ul>
+ <p>
+ Different AI features may process different types of content (audio
+ for captions, video for analysis, etc.), but all follow the same
+ zero-knowledge encryption approach.
+ </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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Analytics</h2>
+ <p>
+ 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">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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
+ <p>We use browser local storage and IndexedDB to:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">
- 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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Third-Party Services</h2>
+ <p>OpenCut integrates with these services:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">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>
+ <section className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Your Rights</h2>
+ <p>You have complete control over your data:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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>
- <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>
- );
+ <section className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Open Source Transparency</h2>
+ <p>
+ 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={SOCIAL_LINKS.github}
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </section>
+
+ <section className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Contact Us</h2>
+ <p>Questions about this privacy policy or how we handle your data?</p>
+ <p>
+ Open an issue on our{" "}
+ <a
+ href={`${SOCIAL_LINKS.github}/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={SOCIAL_LINKS.x}
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ X (Twitter)
+ </a>
+ .
+ </p>
+ </section>
+
+ <Separator />
+
+ <p className="text-muted-foreground text-sm">
+ Last updated: July 14, 2025
+ </p>
+ </BasePage>
+ );
}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 3d66a3c5..d58c3aac 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,646 +1,956 @@
"use client";
-import {
- Calendar,
- ChevronLeft,
- Loader2,
- MoreHorizontal,
- ArrowDown01,
- Plus,
- Search,
- Trash2,
- Video,
- X,
-} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useState } from "react";
-import { DeleteProjectDialog } from "@/components/delete-project-dialog";
-import { RenameProjectDialog } from "@/components/rename-project-dialog";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import type { TProject } from "@/types/project";
+import { useEditor } from "@/hooks/use-editor";
+import { useProjectsStore } from "./store";
+import type {
+ TProjectMetadata,
+ TProjectSortKey,
+ TProjectSortOption,
+} from "@/types/project";
+import { formatTimeCode } from "@/lib/time";
+import { formatDate } from "@/utils/date";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import {
+ Calendar04Icon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ PlusSignIcon,
+ Search01Icon,
+ Video01Icon,
+ MoreHorizontalIcon,
+ Delete02Icon,
+ Copy01Icon,
+ Edit03Icon,
+ ArrowDown02Icon,
+ InformationCircleIcon,
+} from "@hugeicons/core-free-icons";
+import { OcVideoIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
+import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
+import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
+import { cn } from "@/utils/ui";
+
+const formatProjectDuration = ({
+ duration,
+}: {
+ duration: number | undefined;
+}): string | null => {
+ if (duration === undefined) {
+ return null;
+ }
+
+ const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
+ return formatTimeCode({ timeInSeconds: duration, format });
+};
export default function ProjectsPage() {
- const {
- savedProjects,
- isLoading,
- isInitialized,
- deleteProject,
- createNewProject,
- getFilteredAndSortedProjects,
- } = useProjectStore();
- const [thumbnailCache, setThumbnailCache] = useState<
- Record<string, string | null>
- >({});
- const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
- new Set()
- );
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
- new Set()
- );
- const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortOption, setSortOption] = useState("createdAt-desc");
- const router = useRouter();
+ const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
- const getProjectThumbnail = useCallback(
- async (projectId: string): Promise<string | null> => {
- if (thumbnailCache[projectId] !== undefined) {
- return thumbnailCache[projectId];
- }
+ useEffect(() => {
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
+ }
+ }, [editor.project]);
- setLoadingThumbnails((prev) => new Set(prev).add(projectId));
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+ const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
+ searchQuery,
+ sortOption,
+ });
- try {
- const thumbnail = await useTimelineStore
- .getState()
- .getProjectThumbnail(projectId);
- setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
- return thumbnail;
- } finally {
- setLoadingThumbnails((prev) => {
- const newSet = new Set(prev);
- newSet.delete(projectId);
- return newSet;
- });
- }
- },
- []
- );
+ const isLoading = editor.project.getIsLoading();
+ const isInitialized = editor.project.getIsInitialized();
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
+ return (
+ <div className="bg-background min-h-screen">
+ <MigrationDialog />
+ <ProjectsHeader />
+ <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
+ <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
+ {isLoading || !isInitialized ? (
+ <ProjectsSkeleton />
+ ) : projectsToDisplay.length === 0 ? (
+ <EmptyState />
+ ) : (
+ <div
+ className={
+ viewMode === "grid"
+ ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
+ : "flex flex-col"
+ }
+ >
+ {projectsToDisplay.map((project) => (
+ <ProjectItem
+ key={project.id}
+ project={project}
+ allProjectIds={projectsToDisplay.map((p) => p.id)}
+ />
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
+ return (
+ <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
+ <div className="flex items-center justify-between h-16 pt-2">
+ <div className="flex items-center gap-5">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link href="/" className="text-sm sm:text-base">
+ Home
+ </Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage className="text-sm sm:text-base font-medium">
+ All projects
+ </BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
+ <div className="hidden md:flex rounded-md border p-1 h-10">
+ <button
+ type="button"
+ className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
+ onClick={() => setViewMode({ viewMode: "grid" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "grid" })
+ }
+ aria-label="Grid view"
+ aria-pressed={isHydrated && viewMode === "grid"}
+ >
+ <HugeiconsIcon icon={GridViewIcon} className="size-4" />
+ </button>
+ <button
+ type="button"
+ className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
+ onClick={() => setViewMode({ viewMode: "list" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "list" })
+ }
+ aria-label="List view"
+ aria-pressed={isHydrated && viewMode === "list"}
+ >
+ <HugeiconsIcon
+ icon={LeftToRightListDashIcon}
+ className="size-4"
+ />
+ </button>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 md:gap-4">
+ <SearchBar className="hidden md:block" />
+ <NewProjectButton />
+ </div>
+ </div>
+ <SearchBar className="block md:hidden mb-4" />
+ </header>
+ );
+}
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
+const SORT_LABELS: Record<TProjectSortKey, string> = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.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>
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
- <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>
- <div className="flex items-center gap-0">
- <TooltipProvider>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="secondary"
- className="justify-center items-center w-9 h-9"
- >
- <ArrowDown01
- strokeWidth={1.5}
- className="!size-[1.05rem]"
- />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => {
- if (sortOption.startsWith("createdAt")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "createdAt-desc"
- : "createdAt-asc"
- );
- } else {
- setSortOption("createdAt-asc");
- }
- }}
- >
- Created{" "}
- {sortOption.startsWith("createdAt") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => {
- if (sortOption.startsWith("name")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "name-desc"
- : "name-asc"
- );
- } else {
- setSortOption("name-asc");
- }
- }}
- >
- Name{" "}
- {sortOption.startsWith("name") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- <TooltipContent>
- <p>
- Sort by{" "}
- {sortOption.startsWith("createdAt") ? "date" : "name"} (
- {sortOption.endsWith("asc") ? "ascending" : "descending"})
- </p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </div>
+ return (
+ <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
+ <div className="flex items-center gap-2">
+ <Label
+ className="flex items-center gap-3 cursor-pointer px-2"
+ htmlFor="select-all-projects"
+ >
+ <Checkbox
+ className="size-5"
+ id="select-all-projects"
+ checked={
+ isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
+ }
+ onCheckedChange={(checked) =>
+ handleSelectAll({ checked: checked === true })
+ }
+ />
+ <span className="text-muted-foreground hidden md:block">
+ Select all
+ </span>
+ </Label>
- {isSelectionMode && sortedProjects.length > 0 && (
- <button
- type="button"
- onClick={() => handleSelectAll(!allSelected)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- handleSelectAll(!allSelected);
- }
- }}
- className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
- tabIndex={0}
- >
- <Checkbox checked={someSelected ? "indeterminate" : allSelected} />
- <span className="text-sm font-medium">
- {allSelected ? "Deselect All" : "Select All"}
- </span>
- <span className="text-sm text-muted-foreground">
- ({selectedProjects.size} of {sortedProjects.length} selected)
- </span>
- </button>
- )}
+ <div className="h-4 w-px bg-border/50" />
- {isLoading || !isInitialized ? (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {Array.from({ length: 8 }, (_, index) => (
- <div
- key={`skeleton-${index}-${Date.now()}`}
- className="overflow-hidden bg-background border-none p-0"
- >
- <Skeleton className="aspect-square w-full bg-muted/50" />
- <div className="px-0 pt-5 flex flex-col gap-1">
- <Skeleton className="h-4 w-3/4 bg-muted/50" />
- <div className="flex items-center gap-1.5">
- <Skeleton className="h-4 w-4 bg-muted/50" />
- <Skeleton className="h-4 w-24 bg-muted/50" />
- </div>
- </div>
- </div>
- ))}
- </div>
- ) : savedProjects.length === 0 ? (
- <NoProjects onCreateProject={handleCreateProject} />
- ) : sortedProjects.length === 0 ? (
- <NoResults
- searchQuery={searchQuery}
- onClearSearch={() => setSearchQuery("")}
- />
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {sortedProjects.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- isSelectionMode={isSelectionMode}
- isSelected={selectedProjects.has(project.id)}
- onSelect={handleSelectProject}
- getProjectThumbnail={getProjectThumbnail}
- />
- ))}
- </div>
- )}
- </main>
+ <SortDropdown>
+ <Button variant="text" className="text-muted-foreground pl-2">
+ {SORT_LABELS[sortKey]}
+ </Button>
+ </SortDropdown>
+ <Button
+ type="button"
+ variant="text"
+ className="text-muted-foreground"
+ onClick={() =>
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ })
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ });
+ }
+ }}
+ aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown02Icon}
+ className={sortOrder === "asc" ? "rotate-180" : ""}
+ />
+ </Button>
- <DeleteProjectDialog
- isOpen={isBulkDeleteDialogOpen}
- onOpenChange={setIsBulkDeleteDialogOpen}
- onConfirm={handleBulkDelete}
- />
- </div>
- );
+ <div className="h-4 w-px bg-border/50 block md:hidden" />
+
+ <div className="flex md:hidden items-center gap-4">
+ <Button
+ variant="text"
+ onClick={() => setViewMode({ viewMode: "grid" })}
+ >
+ <HugeiconsIcon
+ icon={GridViewIcon}
+ className={cn(
+ viewMode === "grid" ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ <Button
+ variant="text"
+ onClick={() => setViewMode({ viewMode: "list" })}
+ >
+ <HugeiconsIcon
+ icon={LeftToRightListDashIcon}
+ className={cn(
+ viewMode === "list" ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ </div>
+ </div>
+ {selectedProjectCount > 0 ? <ProjectActions /> : null}
+ </div>
+ );
}
-interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
- getProjectThumbnail: (projectId: string) => Promise<string | null>;
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+ <div className="block md:hidden">
+ <Button
+ size="icon"
+ variant="outline"
+ className="size-10.5 rounded-full"
+ >
+ <HugeiconsIcon icon={Search01Icon} />
+ </Button>
+ </div>
+ ) : (
+ <div className={cn("relative", className)}>
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
+ aria-hidden="true"
+ />
+ <Input
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(event) => setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+ </div>
+ )}
+ </>
+ );
}
-function ProjectCard({
- project,
- isSelectionMode = false,
- isSelected = false,
- onSelect,
- getProjectThumbnail,
-}: ProjectCardProps) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
- const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+const PROJECT_ACTIONS = [
+ {
+ id: "duplicate",
+ label: "Duplicate",
+ icon: Copy01Icon,
+ variant: "outline" as const,
+ },
+ {
+ id: "delete",
+ label: "Delete",
+ icon: Delete02Icon,
+ variant: "destructive-foreground" as const,
+ },
+] as const;
- useEffect(() => {
- const loadThumbnail = async () => {
- setIsLoadingThumbnail(true);
- try {
- const thumbnail = await getProjectThumbnail(project.id);
- setDynamicThumbnail(thumbnail);
- } finally {
- setIsLoadingThumbnail(false);
- }
- };
- loadThumbnail();
- }, [project.id, getProjectThumbnail]);
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
+}
- const handleDeleteProject = async () => {
- await deleteProject(project.id);
- setIsDropdownOpen(false);
- };
+async function renameProject({
+ editor,
+ id,
+ name,
+}: {
+ editor: ReturnType<typeof useEditor>;
+ id: string;
+ name: string;
+}) {
+ await editor.project.renameProject({ id, name });
+}
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
- const handleCardKeyDown = (e: React.KeyboardEvent) => {
- if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
- const cardContent = (
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted transition-opacity ${
- isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
- }`}
- >
- {isSelectionMode && (
- <div className="absolute top-3 left-3 z-10">
- <div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-xs border flex items-center justify-center">
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
- </div>
- </div>
- )}
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
- <div className="absolute inset-0">
- {isLoadingThumbnail ? (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Loader2 className="h-12 w-12 text-muted-foreground animate-spin" />
- </div>
- ) : dynamicThumbnail ? (
- <Image
- src={dynamicThumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Video className="h-12 w-12 shrink-0 text-muted-foreground" />
- </div>
- )}
- </div>
- </div>
+ const actionHandlers: Record<string, () => void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
- <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>
+ return (
+ <>
+ <div className="flex items-center gap-2.5 px-3">
+ <div className="hidden sm:flex items-center gap-2.5">
+ {PROJECT_ACTIONS.map((action) => (
+ <Button
+ key={action.id}
+ size="icon"
+ variant={action.variant}
+ className="size-9"
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ </Button>
+ ))}
+ </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>
- );
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild className="sm:hidden">
+ <Button size="icon" variant="outline" className="size-9">
+ <HugeiconsIcon icon={MoreHorizontalIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {PROJECT_ACTIONS.map((action) => (
+ <DropdownMenuItem
+ key={action.id}
+ variant={action.id === "delete" ? "destructive" : undefined}
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ {action.label}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
- return (
- <>
- {isSelectionMode ? (
- <button
- type="button"
- onClick={handleCardClick}
- onKeyDown={handleCardKeyDown}
- className="block group cursor-pointer w-full text-left"
- >
- {cardContent}
- </button>
- ) : (
- <Link href={`/editor/${project.id}`} className="block group">
- {cardContent}
- </Link>
- )}
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- onConfirm={handleDeleteProject}
- />
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- onConfirm={handleRenameProject}
- projectName={project.name}
- />
- </>
- );
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={selectedProjectNames}
+ onConfirm={handleDeleteConfirm}
+ />
+ </>
+ );
}
-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>
- );
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="center">
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "createdAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "updatedAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "name"}
+ onCheckedChange={() => setSortKey({ sortKey: "name" })}
+ >
+ Name
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "duration"}
+ onCheckedChange={() => setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+ </DropdownMenuCheckboxItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+function NewProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+ <Button
+ size="lg"
+ className="flex px-5 md:px-6"
+ onClick={handleCreateProject}
+ >
+ <span className="text-sm font-medium hidden md:block">New project</span>
+ <span className="text-sm font-medium block md:hidden">New</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>
- );
+function ProjectItem({
+ project,
+ allProjectIds,
+}: {
+ project: TProjectMetadata;
+ allProjectIds: string[];
+}) {
+ const {
+ selectedProjectIds,
+ viewMode,
+ setProjectSelected,
+ selectProjectRange,
+ } = useProjectsStore();
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+ const isSelected = selectedProjectIdSet.has(project.id);
+ const selectedProjectCount = selectedProjectIds.length;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const durationLabel = formatProjectDuration({ duration: project.duration });
+ const isMultiSelect = selectedProjectCount > 1;
+ const isGridView = viewMode === "grid";
+
+ const handleCheckboxChange = ({
+ checked,
+ shiftKey,
+ }: {
+ checked: boolean;
+ shiftKey: boolean;
+ }) => {
+ if (shiftKey && checked) {
+ selectProjectRange({ projectId: project.id, allProjectIds });
+ return;
+ }
+ setProjectSelected({ projectId: project.id, isSelected: checked });
+ };
+
+ const gridContent = (
+ <Card className="bg-background overflow-hidden border-none p-0">
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ {durationLabel && (
+ <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-lg">
+ {durationLabel}
+ </div>
+ )}
+ </div>
+
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
+ {project.name}
+ </h3>
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
+ <HugeiconsIcon icon={Calendar04Icon} className="size-4" />
+ <span>Created {formatDate({ date: project.createdAt })}</span>
+ </div>
+ </CardContent>
+ </Card>
+ );
+
+ const listRowContent = (
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
+ {project.name}
+ </h3>
+
+ <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
+ {durationLabel ?? "—"}
+ </span>
+
+ <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
+ {formatDate({ date: project.createdAt })}
+ </span>
+ </div>
+ );
+
+ const listContent = (
+ <div
+ className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
+ isSelected ? "bg-primary/5" : ""
+ }`}
+ >
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className="size-5 shrink-0"
+ />
+
+ <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
+ {listRowContent}
+ </Link>
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ project={project}
+ variant="list"
+ />
+ )}
+ </div>
+ );
+
+ const cardContent = isGridView ? gridContent : listContent;
+
+ if (!isGridView) {
+ return <div className="group relative">{listContent}</div>;
+ }
+
+ return (
+ <div className="group relative">
+ <Link href={`/editor/${project.id}`} className="block">
+ {cardContent}
+ </Link>
+
+ {isGridView && (
+ <>
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className={`absolute z-10 size-5 top-3 left-3 ${
+ isSelected || isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ />
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ project={project}
+ />
+ )}
+ </>
+ )}
+ </div>
+ );
}
-function NoResults({
- searchQuery,
- onClearSearch,
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ project,
+ variant = "grid",
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+ variant?: "grid" | "list";
}) {
- 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>
- );
+ const editor = useEditor();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent<HTMLButtonElement>;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleRename = () => {
+ setIsRenameDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: [project.id] });
+ onOpenChange(false);
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: [project.id] });
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleInfoClick = () => {
+ setIsInfoDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const isGrid = variant === "grid";
+
+ return (
+ <>
+ <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <DropdownMenuTrigger asChild>
+ <Button
+ type="button"
+ variant="background"
+ className={
+ isGrid
+ ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
+ : "!bg-transparent !shadow-none"
+ }
+ size="icon"
+ aria-label="Project menu"
+ onClick={(event) =>
+ handleMenuClick({
+ event: event as unknown as MouseEvent<HTMLButtonElement>,
+ })
+ }
+ onMouseDown={(event) => event.stopPropagation()}
+ onKeyDown={(event) =>
+ handleMenuKeyDown({
+ event: event as unknown as KeyboardEvent<HTMLButtonElement>,
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={MoreHorizontalIcon}
+ className="text-foreground"
+ aria-hidden="true"
+ />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuItem onClick={handleRename}>
+ <HugeiconsIcon icon={Edit03Icon} />
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDuplicate}>
+ <HugeiconsIcon icon={Copy01Icon} />
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleInfoClick}>
+ <HugeiconsIcon icon={InformationCircleIcon} />
+ Info
+ </DropdownMenuItem>
+ <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ projectName={project.name}
+ onConfirm={async (newName) => {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={[project.name]}
+ onConfirm={handleDeleteConfirm}
+ />
+
+ <ProjectInfoDialog
+ isOpen={isInfoDialogOpen}
+ onOpenChange={setIsInfoDialogOpen}
+ project={project}
+ />
+ </>
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, index) => `skeleton-${index}`,
+ );
+
+ return (
+ <div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
+ {skeletonIds.map((skeletonId) => (
+ <Card
+ key={skeletonId}
+ className="bg-background overflow-hidden border-none p-0"
+ >
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ <Skeleton className="bg-muted/50 size-full" />
+ </div>
+ </div>
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <Skeleton className="bg-muted/50 h-4 w-3/4" />
+ <div className="text-muted-foreground flex items-center gap-1.5">
+ <Skeleton className="bg-muted/50 size-4" />
+ <Skeleton className="bg-muted/50 h-4 w-24" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+function EmptyState() {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+ const router = useRouter();
+ const editor = useEditor();
+ const savedProjects = editor.project.getSavedProjects();
+
+ const handleCreateProject = async () => {
+ try {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ } catch (error) {
+ toast.error("Failed to create project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ };
+
+ if (savedProjects.length > 0) {
+ return (
+ <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
+ <div className="flex flex-col items-center gap-8">
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
+ />
+ <div className="flex flex-col items-center gap-3">
+ <h3 className="text-lg font-medium">No results found</h3>
+ <p className="text-muted-foreground max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ </div>
+ </div>
+ <Button
+ onClick={() => setSearchQuery({ query: "" })}
+ variant="outline"
+ size="lg"
+ >
+ Clear search
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
+ <div className="flex flex-col items-center gap-2">
+ <div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
+ <HugeiconsIcon
+ icon={Video01Icon}
+ className="text-muted-foreground size-8"
+ />
+ </div>
+ <h3 className="text-lg font-medium">No projects yet</h3>
+ <p className="text-muted-foreground max-w-md">
+ Start creating your first project. Import media, edit, and export your
+ videos. All privately.
+ </p>
+ </div>
+ <Button size="lg" className="gap-2" onClick={handleCreateProject}>
+ <HugeiconsIcon icon={PlusSignIcon} />
+ Create your first project
+ </Button>
+ </div>
+ );
}
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
new file mode 100644
index 00000000..eaf8f947
--- /dev/null
+++ b/apps/web/src/app/projects/store.ts
@@ -0,0 +1,129 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { TProjectSortKey } from "@/types/project";
+
+export type ProjectsViewMode = "grid" | "list";
+
+interface ProjectsState {
+ searchQuery: string;
+ sortKey: TProjectSortKey;
+ sortOrder: "asc" | "desc";
+ viewMode: ProjectsViewMode;
+ selectedProjectIds: string[];
+ lastSelectedProjectId: string | null;
+ isHydrated: boolean;
+ setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
+ setSearchQuery: ({ query }: { query: string }) => void;
+ setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
+ setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
+ toggleSortOrder: () => void;
+ setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
+ setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
+ clearSelectedProjects: () => void;
+ setProjectSelected: ({
+ projectId,
+ isSelected,
+ }: {
+ projectId: string;
+ isSelected: boolean;
+ }) => void;
+ selectProjectRange: ({
+ projectId,
+ allProjectIds,
+ }: {
+ projectId: string;
+ allProjectIds: string[];
+ }) => void;
+}
+
+const getNextSelectedProjectIds = ({
+ selectedProjectIds,
+ projectId,
+ isSelected,
+}: {
+ selectedProjectIds: string[];
+ projectId: string;
+ isSelected: boolean;
+}): string[] => {
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+
+ if (isSelected) {
+ selectedProjectIdSet.add(projectId);
+ return Array.from(selectedProjectIdSet);
+ }
+
+ selectedProjectIdSet.delete(projectId);
+ return Array.from(selectedProjectIdSet);
+};
+
+export const useProjectsStore = create<ProjectsState>()(
+ persist(
+ (set) => ({
+ searchQuery: "",
+ sortKey: "createdAt",
+ sortOrder: "desc",
+ viewMode: "grid",
+ selectedProjectIds: [],
+ lastSelectedProjectId: null,
+ isHydrated: false,
+ setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
+ setSearchQuery: ({ query }) => set({ searchQuery: query }),
+ setSortKey: ({ sortKey }) => set({ sortKey }),
+ setSortOrder: ({ sortOrder }) => set({ sortOrder }),
+ toggleSortOrder: () =>
+ set((state) => ({
+ sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
+ })),
+ setViewMode: ({ viewMode }) => set({ viewMode }),
+ setSelectedProjects: ({ projectIds }) =>
+ set({ selectedProjectIds: projectIds }),
+ clearSelectedProjects: () =>
+ set({ selectedProjectIds: [], lastSelectedProjectId: null }),
+ setProjectSelected: ({ projectId, isSelected }) =>
+ set((state) => ({
+ selectedProjectIds: getNextSelectedProjectIds({
+ selectedProjectIds: state.selectedProjectIds,
+ projectId,
+ isSelected,
+ }),
+ lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
+ })),
+ selectProjectRange: ({ projectId, allProjectIds }) =>
+ set((state) => {
+ const anchorId = state.lastSelectedProjectId;
+ if (!anchorId) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const anchorIndex = allProjectIds.indexOf(anchorId);
+ const targetIndex = allProjectIds.indexOf(projectId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const startIndex = Math.min(anchorIndex, targetIndex);
+ const endIndex = Math.max(anchorIndex, targetIndex);
+ const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
+
+ const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
+ return {
+ selectedProjectIds: Array.from(merged),
+ };
+ }),
+ }),
+ {
+ name: "projects-view-mode",
+ partialize: (state) => ({ viewMode: state.viewMode }),
+ onRehydrateStorage: () => (state) => {
+ state?.setIsHydrated({ isHydrated: true });
+ },
+ },
+ ),
+);
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index b5397b63..5227f808 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -1,247 +1,144 @@
-import { Metadata } from "next";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
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";
+import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
+import { cn } from "@/utils/ui";
-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: "Completed",
- type: "complete",
- },
- },
- {
- 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",
- },
- },
+type StatusType = "complete" | "pending" | "default" | "info";
+
+interface Status {
+ text: string;
+ type: StatusType;
+}
+
+interface RoadmapItem {
+ title: string;
+ description: string;
+ status: Status;
+}
+
+const roadmapItems: RoadmapItem[] = [
+ {
+ 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:
+ "Build 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: "Essential functionality",
+ description:
+ "Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
+ status: {
+ text: "In progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Badge (potentially)",
+ description:
+ 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
+ status: {
+ text: "Not started",
+ type: "default",
+ },
+ },
];
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"],
- },
+ 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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-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-normal">
- {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>
+ return (
+ <BasePage
+ title="Roadmap"
+ description="What's coming next for OpenCut (last updated: July 14, 2025)"
+ >
+ <div className="mx-auto flex max-w-4xl flex-col gap-16">
+ <div className="flex flex-col gap-6">
+ {roadmapItems.map((item, index) => (
+ <RoadmapItem key={item.title} item={item} index={index} />
+ ))}
+ </div>
+ <GitHubContributeSection
+ title="Want to help?"
+ description="OpenCut is open source and built by the community. Every contribution,
+ no matter how small, helps us build the best free video editor
+ possible."
+ />
+ </div>
+ </BasePage>
+ );
+}
+
+function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
+ return (
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2 text-lg font-medium">
+ <span className="leading-normal select-none">{index + 1}</span>
+ <h3>{item.title}</h3>
+ <StatusBadge status={item.status} className="ml-1" />
+ </div>
+ <div className="text-foreground/70 leading-relaxed">
+ <ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
+ </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>
- );
+function StatusBadge({
+ status,
+ className,
+}: {
+ status: Status;
+ className?: string;
+}) {
+ return (
+ <Badge
+ className={cn("shadow-none", className, {
+ "bg-green-500! text-white": status.type === "complete",
+ "bg-yellow-500! text-white": status.type === "pending",
+ "bg-blue-500! text-white": status.type === "info",
+ "bg-foreground/10! text-accent-foreground": status.type === "default",
+ })}
+ >
+ {status.text}
+ </Badge>
+ );
}
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
index ad6c31c8..60c13829 100644
--- a/apps/web/src/app/robots.ts
+++ b/apps/web/src/app/robots.ts
@@ -1,13 +1,13 @@
import type { MetadataRoute } from "next";
-import { SITE_URL } from "@/constants/site";
+import { SITE_URL } from "@/constants/site-constants";
export default function robots(): MetadataRoute.Robots {
- return {
- rules: {
- userAgent: "*",
- allow: "/",
- disallow: ["/_next/", "/projects/", "/editor/"],
- },
- sitemap: `${SITE_URL}/sitemap.xml`,
- };
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/_next/", "/projects/", "/editor/"],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ };
}
diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts
index ab645655..909d5a1a 100644
--- a/apps/web/src/app/rss.xml/route.ts
+++ b/apps/web/src/app/rss.xml/route.ts
@@ -1,46 +1,46 @@
import { Feed } from "feed";
-import { getPosts } from "@/lib/blog-query";
-import { SITE_INFO, SITE_URL } from "@/constants/site";
+import { getPosts } from "@/lib/blog/query";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export async function GET() {
- try {
- const { posts } = await getPosts();
+ try {
+ const { posts } = await getPosts();
- const feed = new Feed({
- title: `${SITE_INFO.title} Blog`,
- description: SITE_INFO.description,
- id: `${SITE_URL}`,
- link: `${SITE_URL}/blog/`,
- language: "en",
- image: `${SITE_INFO.openGraphImage}`,
- favicon: `${SITE_INFO.favicon}`,
- copyright: `All rights reserved ${new Date().getFullYear()}, ${
- SITE_INFO.title
- }`,
- });
+ const feed = new Feed({
+ title: `${SITE_INFO.title} Blog`,
+ description: SITE_INFO.description,
+ id: `${SITE_URL}`,
+ link: `${SITE_URL}/blog/`,
+ language: "en",
+ image: `${SITE_INFO.openGraphImage}`,
+ favicon: `${SITE_INFO.favicon}`,
+ copyright: `All rights reserved ${new Date().getFullYear()}, ${
+ SITE_INFO.title
+ }`,
+ });
- for (const post of posts) {
- feed.addItem({
- title: post.title,
- id: `${SITE_URL}/blog/${post.slug}`,
- link: `${SITE_URL}/blog/${post.slug}`,
- description: post.description,
- author: post.authors.map((author) => ({
- name: author.name,
- })),
- date: new Date(post.publishedAt),
- image: post.coverImage || SITE_INFO.openGraphImage,
- });
- }
+ for (const post of posts) {
+ feed.addItem({
+ title: post.title,
+ id: `${SITE_URL}/blog/${post.slug}`,
+ link: `${SITE_URL}/blog/${post.slug}`,
+ description: post.description,
+ author: post.authors.map((author) => ({
+ name: author.name,
+ })),
+ date: new Date(post.publishedAt),
+ image: post.coverImage || SITE_INFO.openGraphImage,
+ });
+ }
- return new Response(feed.rss2(), {
- headers: {
- "Content-Type": "text/xml",
- "Cache-Control": "public, max-age=86400, stale-while-revalidate",
- },
- });
- } catch (error) {
- console.error("Error generating RSS feed", error);
- return new Response("Internal Server Error", { status: 500 });
- }
+ return new Response(feed.rss2(), {
+ headers: {
+ "Content-Type": "text/xml",
+ "Cache-Control": "public, max-age=86400, stale-while-revalidate",
+ },
+ });
+ } catch (error) {
+ console.error("Error generating RSS feed", error);
+ return new Response("Internal Server Error", { status: 500 });
+ }
}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
index 81a5ebf4..fed1aed6 100644
--- a/apps/web/src/app/sitemap.ts
+++ b/apps/web/src/app/sitemap.ts
@@ -1,61 +1,61 @@
-import { SITE_URL } from "@/constants/site";
-import { getPosts } from "@/lib/blog-query";
+import { SITE_URL } from "@/constants/site-constants";
+import { getPosts } from "@/lib/blog/query";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
- const data = await getPosts();
+ const data = await getPosts();
- const postPages: MetadataRoute.Sitemap =
- data?.posts?.map((post) => ({
- url: `${SITE_URL}/blog/${post.slug}`,
- lastModified: new Date(post.publishedAt),
- changeFrequency: "weekly",
- priority: 0.8,
- })) ?? [];
+ const postPages: MetadataRoute.Sitemap =
+ data?.posts?.map((post) => ({
+ url: `${SITE_URL}/blog/${post.slug}`,
+ lastModified: new Date(post.publishedAt),
+ changeFrequency: "weekly",
+ priority: 0.8,
+ })) ?? [];
- return [
- {
- url: SITE_URL,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/contributors`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/roadmap`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/privacy`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/terms`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/why-not-capcut`,
- lastModified: new Date(),
- changeFrequency: "yearly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/blog`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- ...postPages,
- ];
+ return [
+ {
+ url: SITE_URL,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/contributors`,
+ lastModified: new Date(),
+ changeFrequency: "daily",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/roadmap`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/privacy`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/terms`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/why-not-capcut`,
+ lastModified: new Date(),
+ changeFrequency: "yearly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/blog`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ...postPages,
+ ];
}
diff --git a/apps/web/src/app/sponsors/page.tsx b/apps/web/src/app/sponsors/page.tsx
new file mode 100644
index 00000000..5d8b4e6d
--- /dev/null
+++ b/apps/web/src/app/sponsors/page.tsx
@@ -0,0 +1,82 @@
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { BasePage } from "@/app/base-page";
+import { Card, CardContent } from "@/components/ui/card";
+import { SPONSORS, type Sponsor } from "@/constants/site-constants";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
+
+export const metadata: Metadata = {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ openGraph: {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ type: "website",
+ },
+};
+
+export default function SponsorsPage() {
+ return (
+ <BasePage>
+ <div className="flex flex-col gap-8 text-center">
+ <h1 className="text-5xl font-bold tracking-tight md:text-6xl">
+ Sponsors
+ </h1>
+ <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
+ Support OpenCut and help us build the future of privacy-first video
+ editing.
+ </p>
+ </div>
+ <SponsorsGrid />
+ </BasePage>
+ );
+}
+
+function SponsorsGrid() {
+ return (
+ <div className="grid gap-6 sm:grid-cols-2">
+ {SPONSORS.map((sponsor) => (
+ <SponsorCard key={sponsor.name} sponsor={sponsor} />
+ ))}
+ </div>
+ );
+}
+
+function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
+ return (
+ <Link
+ href={sponsor.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="size-full"
+ >
+ <Card className="h-full">
+ <CardContent className="flex h-full flex-col justify-center gap-8 p-8">
+ <Image
+ src={sponsor.logo}
+ alt={`${sponsor.name} logo`}
+ width={50}
+ height={50}
+ className="object-contain"
+ />
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <h3 className="text-xl font-semibold group-hover:underline">
+ {sponsor.name}
+ </h3>
+ <HugeiconsIcon
+ icon={LinkSquare02Icon}
+ className="text-muted-foreground size-4"
+ />
+ </div>
+ <p className="text-muted-foreground">{sponsor.description}</p>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ );
+}
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
index ee202d8b..d5d43ba7 100644
--- a/apps/web/src/app/terms/page.tsx
+++ b/apps/web/src/app/terms/page.tsx
@@ -1,317 +1,295 @@
-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";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
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",
- },
+ 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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-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-xs 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>
+ return (
+ <BasePage
+ title="Terms of service"
+ description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
+ >
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem
+ value="quick-summary"
+ className="rounded-2xl border px-5"
+ >
+ <AccordionTrigger className="no-underline!">
+ Quick summary
+ </AccordionTrigger>
+ <AccordionContent>
+ <h3 className="mb-3 text-lg font-medium">
+ You own your content, we own nothing.
+ </h3>
+ <ol className="list-decimal space-y-2 pl-6">
+ <li>
+ Your content stays private - basic editing is local, AI features
+ use encrypted uploads
+ </li>
+ <li>
+ We never claim ownership of your content, even when processing
+ AI features
+ </li>
+ <li>
+ Free for personal and commercial use with no watermarks or
+ restrictions
+ </li>
+ <li>Don't use OpenCut for illegal activities or harassment</li>
+ <li>
+ Service provided "as is" - we can't guarantee perfect uptime
+ </li>
+ <li>
+ Open source means you can review our code and self-host if
+ needed
+ </li>
+ <li>
+ You can delete your account anytime and keep using your exported
+ videos
+ </li>
+ </ol>
+ <p className="mt-4">
+ Questions? Email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ </p>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
- <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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
+ <p>
+ <strong>You own everything you create.</strong> OpenCut processes
+ basic editing locally on your device. For AI features, content is
+ encrypted before upload and we cannot access your original files. 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 space-y-2 pl-6">
+ <li>
+ Your content remains private and under your control at all times
+ </li>
+ <li>You retain all intellectual property rights to your content</li>
+ <li>
+ Even when using AI features, we cannot access your unencrypted
+ 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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
+ <p>OpenCut is free for personal and commercial use. You can:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">
+ AI Features and Data Processing
+ </h2>
+ <p>
+ OpenCut offers optional AI-powered features that require server
+ processing:
+ </p>
+ <ul className="list-disc space-y-2 pl-6">
+ <li>
+ AI features (auto captions, content analysis, etc.) are completely
+ optional
+ </li>
+ <li>Your content is encrypted on your device before any upload</li>
+ <li>
+ We use zero-knowledge encryption - we cannot decrypt your content
+ </li>
+ <li>Encrypted content is deleted immediately after processing</li>
+ <li>
+ You maintain full ownership and control of your content throughout
+ </li>
+ </ul>
+ <p>
+ By using AI features, you consent to the temporary, encrypted
+ processing of your content as described in our Privacy Policy. You can
+ always choose to use only local editing features.
+ </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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Account and Service</h2>
+ <p>To use certain features, you may create an account:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">
- 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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Open Source Benefits</h2>
+ <p>Because OpenCut is open source, you have additional rights:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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={SOCIAL_LINKS.github}
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Third-Party Content</h2>
+ <p>
+ When using OpenCut, make sure you have the right to use any content
+ you import:
+ </p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">
- 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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Limitations and Liability</h2>
+ <p>
+ OpenCut is provided free of charge. To the extent permitted by law:
+ </p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">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 className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Service Changes</h2>
+ <p>We may update OpenCut and these terms:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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">
- 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>
+ <section className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Termination</h2>
+ <p>You can stop using OpenCut at any time:</p>
+ <ul className="list-disc space-y-2 pl-6">
+ <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>
- <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>
- );
+ <section className="flex flex-col gap-3">
+ <h2 className="text-2xl font-semibold">Contact Us</h2>
+ <p>Questions about these terms or need to report an issue?</p>
+ <p>
+ Contact us through our{" "}
+ <a
+ href={`${SOCIAL_LINKS.github}/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={SOCIAL_LINKS.x}
+ 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>
+ <Separator />
+ <p className="text-muted-foreground text-sm">
+ Last updated: July 14, 2025
+ </p>
+ </BasePage>
+ );
}
diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx
deleted file mode 100644
index b05860a6..00000000
--- a/apps/web/src/app/why-not-capcut/page.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
- <div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-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>
- );
-}