From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/app/(auth)/login/page.tsx | 146 --- apps/web/src/app/(auth)/signup/page.tsx | 162 --- apps/web/src/app/animation/page.tsx | 24 - apps/web/src/app/api/auth/[...all]/route.ts | 2 +- apps/web/src/app/api/get-upload-url/route.ts | 128 -- apps/web/src/app/api/health/route.ts | 6 +- apps/web/src/app/api/sounds/search/route.ts | 467 +++---- apps/web/src/app/api/transcribe/route.ts | 189 --- apps/web/src/app/api/waitlist/export/route.ts | 83 -- apps/web/src/app/base-page.tsx | 53 + apps/web/src/app/blog/[slug]/page.tsx | 264 ++-- apps/web/src/app/blog/page.tsx | 151 +-- apps/web/src/app/contributors/page.tsx | 536 ++++---- apps/web/src/app/editor/[project_id]/layout.tsx | 9 - apps/web/src/app/editor/[project_id]/page.tsx | 543 ++------ apps/web/src/app/globals.css | 430 ++++--- apps/web/src/app/layout.tsx | 86 +- apps/web/src/app/metadata.ts | 164 +-- apps/web/src/app/page.tsx | 22 +- apps/web/src/app/privacy/page.tsx | 512 ++++---- apps/web/src/app/projects/page.tsx | 1552 ++++++++++++++--------- apps/web/src/app/projects/store.ts | 129 ++ apps/web/src/app/roadmap/page.tsx | 371 ++---- apps/web/src/app/robots.ts | 18 +- apps/web/src/app/rss.xml/route.ts | 78 +- apps/web/src/app/sitemap.ts | 110 +- apps/web/src/app/sponsors/page.tsx | 82 ++ apps/web/src/app/terms/page.tsx | 580 ++++----- apps/web/src/app/why-not-capcut/page.tsx | 191 --- 29 files changed, 3104 insertions(+), 3984 deletions(-) delete mode 100644 apps/web/src/app/(auth)/login/page.tsx delete mode 100644 apps/web/src/app/(auth)/signup/page.tsx delete mode 100644 apps/web/src/app/animation/page.tsx delete mode 100644 apps/web/src/app/api/get-upload-url/route.ts delete mode 100644 apps/web/src/app/api/transcribe/route.ts delete mode 100644 apps/web/src/app/api/waitlist/export/route.ts create mode 100644 apps/web/src/app/base-page.tsx delete mode 100644 apps/web/src/app/editor/[project_id]/layout.tsx create mode 100644 apps/web/src/app/projects/store.ts create mode 100644 apps/web/src/app/sponsors/page.tsx delete mode 100644 apps/web/src/app/why-not-capcut/page.tsx (limited to 'apps/web/src/app') 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 ( -
- - - - Welcome back - - Sign in to your account to continue - - - - - -
- } - > -
- {error && ( - - Error - {error} - - )} - - -
-
- -
-
- - Or continue with - -
-
-
-
- - setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
- -
-
-
- Don't have an account?{" "} - - Sign up - -
- - - - - ); -}; - -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 ( -
- - - - - Create your account - - - Get started with your free account today - - - - - -
- } - > -
- {error && ( - - Error - {error} - - )} - -
-
- -
-
- - Or continue with - -
-
-
-
- - setName(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
- -
-
-
- Already have an account?{" "} - - Sign in - -
- - - - - ); -}; - -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 ( -
-
- -
-
-

setIsExpanded(!isExpanded)} - className="cursor-pointer hover:opacity-75 transition-opacity" - > - {isExpanded ? "Collapse" : "Expand"} -

-
-
- ); -} 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, +) { + 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 ( +
+
+
+ {title && description && ( +
+

+ {title} +

+

+ {description} +

+
+ )} + {children} +
+
+
+ ); +} 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 { - const slug = (await params).slug; - - const data = await getSinglePost(slug); - - 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), - ], - }, - }; + const slug = (await params).slug; + + const data = await getSinglePost({ slug }); + + 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: 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, + })); +} + +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({ html: data.post.content }); - return data.posts.map((post) => ({ - slug: post.slug, - })); + return ( + + + + + + ); } -async function Page({ 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 formattedDate = new Date(data.post.publishedAt).toLocaleDateString( - "en-US", - { - day: "numeric", - month: "long", - year: "numeric", - } - ); - - return ( -
-
- -
-
-
-
-
- -
-
- {data.post.coverImage && ( -
- {data.post.title} -
- )} -
- -
- -

- {data.post.title} -

-
- {data.post.authors[0] && ( - <> - {data.post.authors[0].name} -

- {data.post.authors[0].name} -

- - )} -
-
- -
- -
-
-
-
- ); +function PostHeader({ post }: { post: Post }) { + const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", { + day: "numeric", + month: "long", + year: "numeric", + }); + + return ( + <> + {post.coverImage && } + + + + + ); +} + +function PostCoverImage({ post }: { post: Post }) { + return ( +
+ {post.title} +
+ ); } -export default Page; +function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { + return ( +
+ +
+ ); +} + +function PostTitle({ title }: { title: string }) { + return ( +

{title}

+ ); +} + +function PostAuthor({ author }: { author?: Author }) { + if (!author) return null; + + return ( +
+ {author.name} +

{author.name}

+
+ ); +} + +function PostContent({ html }: { html: string }) { + return ( +
+ +
+ ); +} 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
No posts yet
; - - return ( -
-
+ const data = await getPosts(); + if (!data || !data.posts) return
No posts yet
; -
-
-
-
-
+ return ( + +
+ {data.posts.map((post) => ( +
+ + +
+ ))} +
+
+ ); +} -
-
-

- Blog -

-

- Read the latest news and updates about OpenCut, the free and - open-source video editor. -

-
-
- {data.posts.map((post) => ( - - - {post.coverImage && ( -
- {post.title} -
- )} +function BlogPostItem({ post }: { post: Post }) { + return ( + +
+
+

{post.title}

+

{post.description}

+
+ {post.authors && post.authors.length > 0 && ( + + )} +
+ + ); +} - - {post.authors && post.authors.length > 0 && ( -
- {post.authors.map((author, index) => ( -
- - - - {author.name.charAt(0).toUpperCase()} - - - - {author.name} - - {index < post.authors.length - 1 && ( - • - )} -
- ))} -
- )} -

{post.title}

-

{post.description}

-
-
- - ))} -
-
-
-
- ); +function AuthorList({ authors }: { authors: Author[] }) { + return ( +
+ {authors.map((author) => ( +
+ + + + {author.name.charAt(0).toUpperCase()} + + +
+ ))} +
+ ); } 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 { - 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 - ); - - if (!response.ok) { - console.error("Failed to fetch contributors"); - return []; - } - - const contributors = (await response.json()) as Contributor[]; - - const filteredContributors = contributors.filter( - (contributor: Contributor) => contributor.type === "User" - ); - - return filteredContributors; - } catch (error) { - console.error("Error fetching contributors:", error); - return []; - } + try { + const response = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCut-Web-App", + }, + next: { revalidate: 600 }, // 10 minutes + }, + ); + + if (!response.ok) { + console.error("Failed to fetch contributors"); + return []; + } + + const contributors = (await response.json()) as Contributor[]; + + const filteredContributors = contributors.filter( + (contributor) => contributor.type === "User", + ); + + return filteredContributors; + } catch (error) { + console.error("Error fetching contributors:", error); + return []; + } } export default async function ContributorsPage() { - const contributors = await getContributors(); - const topContributors = contributors.slice(0, 2); - const otherContributors = contributors.slice(2); - - return ( -
-
- -
-
-
-
-
- -
-
-
- - - - Open Source - - -

- Contributors -

-

- Meet the amazing developers who are building the future of video - editing -

- -
-
-
- {contributors.length} - contributors -
-
-
- - {contributors.reduce((sum, c) => sum + c.contributions, 0)} - - contributions -
-
-
- - {topContributors.length > 0 && ( -
-
-

- Top Contributors -

-

- Leading the way in contributions -

-
- -
- {topContributors.map((contributor, index) => ( - -
-
- - -
- - - - {contributor.login.charAt(0).toUpperCase()} - - -
-

- {contributor.login} -

-
- - {contributor.contributions} - - contributions -
-
-
-
- - ))} -
-
- )} - - {otherContributors.length > 0 && ( -
-
-

- All Contributors -

-

- Everyone who makes OpenCut better -

-
- -
- {otherContributors.map((contributor, index) => ( - -
- - - - {contributor.login.charAt(0).toUpperCase()} - - -

- {contributor.login} -

-

- {contributor.contributions} -

-
- - ))} -
-
- )} - - {contributors.length === 0 && ( -
-
- -
-

- No contributors found -

-

- Unable to load contributors at the moment. Check back later or - view on GitHub. -

- - - -
- )} + 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, + ); + + return ( + +
+ + +
+ +
+ {topContributors.length > 0 && ( + + )} + {otherContributors.length > 0 && ( + + )} + + +
+
+ ); +} -
-
-

External Tools

-

- Tools we use to build OpenCut -

-
+function StatItem({ value, label }: { value: number; label: string }) { + return ( +
+
+ {value} + {label} +
+ ); +} -
- {EXTERNAL_TOOLS.map((tool, index) => { - const IconComponent = { - MarbleIcon, - VercelIcon, - DataBuddyIcon, - }[tool.icon]; +function TopContributorsSection({ + contributors, +}: { + contributors: Contributor[]; +}) { + return ( +
+
+

Top contributors

+

+ Leading the way in contributions +

+
+ +
+ {contributors.map((contributor) => ( + + ))} +
+
+ ); +} - return ( - - - -
-
- -
-
-

- {tool.name} -

-

- {tool.description} -

-
-
- - ); - })} -
-
+function TopContributorCard({ contributor }: { contributor: Contributor }) { + return ( + + + + + + + {contributor.login.charAt(0).toUpperCase()} + + +
+

{contributor.login}

+
+ {contributor.contributions} + contributions +
+
+
+
+ + ); +} -
-
-

Join the community

-

- OpenCut is built by developers like you. Every contribution, - no matter how small, helps make video editing more accessible - for everyone. -

+function AllContributorsSection({ + contributors, +}: { + contributors: Contributor[]; +}) { + return ( +
+
+

All contributors

+

+ Everyone who makes OpenCut better +

+
+ +
+ {contributors.map((contributor) => ( + +
+ + + + {contributor.login.charAt(0).toUpperCase()} + + +
+

{contributor.login}

+

+ {contributor.contributions} +

+
+
+ + ))} +
+
+ ); +} -
- - - - - - -
-
-
-
-
-
-
- ); +function ExternalToolsSection() { + return ( +
+
+

External tools

+

Tools we use to build OpenCut

+
+ +
+ {EXTERNAL_TOOLS.map((tool, index) => ( + + + + +
+

{tool.name}

+

+ {tool.description} +

+
+
+
+ + ))} +
+
+ ); } 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
{children}
; -} 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>(new Set()); - const isInitializingRef = useRef(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; - } - }; - - initProject(); - - // Cleanup function to cancel async operations - return () => { - isCancelled = true; - isInitializingRef.current = false; - }; - }, [ - projectId, - loadProject, - createNewProject, - router, - isInvalidProjectId, - markProjectIdAsInvalid, - ]); - - return ( - -
- -
- {activePreset === "media" ? ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : activePreset === "inspector" ? ( - - setPropertiesPanel(100 - size)} - className="min-w-0 min-h-0" - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : activePreset === "vertical-preview" ? ( - - setPreviewPanel(100 - size)} - className="min-w-0 min-h-0" - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - - - {/* Main content area */} - - {/* Tools Panel */} - - - - - - - {/* Preview Area */} - - - - - - - - - - - - - + const params = useParams(); + const projectId = params.project_id as string; + + return ( + +
+ +
+ +
+ + +
+
+ ); +} - {/* Timeline */} - - - -
- )} -
- -
-
- ); +function EditorLayout() { + const { panels, setPanel } = usePanelStore(); + + return ( + { + setPanel("mainContent", sizes[0] ?? panels.mainContent); + setPanel("timeline", sizes[1] ?? panels.timeline); + }} + > + + { + setPanel("tools", sizes[0] ?? panels.tools); + setPanel("preview", sizes[1] ?? panels.preview); + setPanel("properties", sizes[2] ?? panels.properties); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); } 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; - - /* 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); - - /* Border radius */ - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 8px); - - /* 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-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - - --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); - - /* 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); - - /* 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-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + /* Responsive breakpoints */ + --breakpoint-xs: 30rem; + + /* Typography */ + --font-sans: var(--font-inter), sans-serif; + + /* 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: 0.82rem; + --radius-md: 0.65rem; + --radius-sm: 0.35rem; + + /* 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-popover: var(--popover); + --color-popover-foreground: var(--popover-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-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-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); + + /* 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); + + /* 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; + + @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; + } + } } @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 ( - - - - - - - - - {children} - - - -