diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/app | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/app')
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> - ); -} |
