diff options
Diffstat (limited to 'apps/web/src/app')
| -rw-r--r-- | apps/web/src/app/api/sounds/search/route.ts | 265 | ||||
| -rw-r--r-- | apps/web/src/app/api/waitlist/route.ts | 105 | ||||
| -rw-r--r-- | apps/web/src/app/api/waitlist/token/route.ts | 61 | ||||
| -rw-r--r-- | apps/web/src/app/blog/[slug]/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/app/blog/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/app/contributors/page.tsx | 10 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/layout.tsx | 13 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 104 | ||||
| -rw-r--r-- | apps/web/src/app/globals.css | 307 | ||||
| -rw-r--r-- | apps/web/src/app/layout.tsx | 6 | ||||
| -rw-r--r-- | apps/web/src/app/privacy/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 18 | ||||
| -rw-r--r-- | apps/web/src/app/roadmap/page.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/app/terms/page.tsx | 6 | ||||
| -rw-r--r-- | apps/web/src/app/why-not-capcut/page.tsx | 4 |
15 files changed, 639 insertions, 288 deletions
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts new file mode 100644 index 00000000..c89bc76c --- /dev/null +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -0,0 +1,265 @@ +import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { env } from "@/env";
+import { baseRateLimit } 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),
+});
+
+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(),
+});
+
+const freesoundResponseSchema = z.object({
+ 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(),
+});
+
+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(),
+});
+
+export async function GET(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 { searchParams } = new URL(request.url);
+
+ 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 (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
+
+ 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 baseUrl = "https://freesound.org/apiv2/search/text/";
+
+ // Use score sorting for search queries, downloads for top sounds
+ const sortParam = query
+ ? sort === "score"
+ ? "score"
+ : `${sort}_desc`
+ : `${sort}_desc`;
+
+ 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",
+ });
+
+ // 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 *]`);
+
+ // 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")'
+ );
+ }
+
+ 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 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 }
+ );
+ }
+
+ 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 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 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 }
+ );
+ }
+
+ 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/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts deleted file mode 100644 index 9f00f221..00000000 --- a/apps/web/src/app/api/waitlist/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db, eq, waitlist } from "@opencut/db"; -import { checkBotId } from "botid/server"; -import { nanoid } from "nanoid"; -import { waitlistRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; -import { env } from "@/env"; -import { cookies } from "next/headers"; -import crypto from "crypto"; - -const waitlistSchema = z.object({ - email: z.string().email("Invalid email format").min(1, "Email is required"), -}); - -const CSRF_TOKEN_NAME = "waitlist-csrf"; -const TOKEN_EXPIRY = 60 * 60 * 1000; - -async function validateCSRFToken(request: NextRequest): Promise<boolean> { - const clientToken = request.headers.get("x-csrf-token"); - if (!clientToken) return false; - - const cookieStore = await cookies(); - const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value; - if (!cookieValue) return false; - - const [token, timestamp, signature] = cookieValue.split(":"); - if (!token || !timestamp || !signature) return false; - - if (clientToken !== token) return false; - - const now = Date.now(); - const tokenTime = parseInt(timestamp); - if (now - tokenTime > TOKEN_EXPIRY) return false; - - const expectedSignature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); - - return signature === expectedSignature; -} - -export async function POST(request: NextRequest) { - const verification = await checkBotId(); - - if (verification.isBot) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; - const { success } = await waitlistRateLimit.limit(identifier); - - if (!success) { - return NextResponse.json( - { error: "Too many requests. Please try again later." }, - { status: 429 } - ); - } - const isValidToken = await validateCSRFToken(request); - if (!isValidToken) { - return NextResponse.json( - { error: "Invalid security token" }, - { status: 403 } - ); - } - - try { - const body = await request.json(); - const { email } = waitlistSchema.parse(body); - - const existingEmail = await db - .select() - .from(waitlist) - .where(eq(waitlist.email, email.toLowerCase())) - .limit(1); - - if (existingEmail.length > 0) { - return NextResponse.json( - { error: "Email already registered" }, - { status: 409 } - ); - } - - await db.insert(waitlist).values({ - id: nanoid(), - email: email.toLowerCase(), - }); - - return NextResponse.json( - { message: "Successfully joined waitlist!" }, - { status: 201 } - ); - } catch (error) { - if (error instanceof z.ZodError) { - const firstError = error.errors[0]; - return NextResponse.json({ error: firstError.message }, { status: 400 }); - } - - console.error("Waitlist signup error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts deleted file mode 100644 index fc2ff32c..00000000 --- a/apps/web/src/app/api/waitlist/token/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import crypto from "crypto"; -import { env } from "@/env"; - -const CSRF_TOKEN_NAME = "waitlist-csrf"; -const TOKEN_EXPIRY = 60 * 60 * 1000; -const allowedHosts = - env.NODE_ENV === "development" - ? ["localhost:3000", "127.0.0.1:3000"] - : ["opencut.app", "www.opencut.app"]; - -export async function GET(request: NextRequest) { - const referer = request.headers.get("referer"); - const host = request.headers.get("host"); - - if (referer) { - const refererUrl = new URL(referer); - - if ( - !allowedHosts.some( - (allowed) => - refererUrl.host === allowed || refererUrl.host.endsWith(allowed) - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else if (host) { - if ( - !allowedHosts.some( - (allowed) => host === allowed || host.endsWith(allowed) - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - if (!env.BETTER_AUTH_SECRET) { - throw new Error("BETTER_AUTH_SECRET must be configured"); - } - - const token = crypto.randomBytes(32).toString("hex"); - const timestamp = Date.now(); - const signature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); - - const cookieStore = await cookies(); - cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict", - maxAge: TOKEN_EXPIRY / 1000, - path: "/", - }); - - return NextResponse.json({ token }); -} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 0de1e0a8..d759050b 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -87,8 +87,8 @@ async function Page({ params }: PageProps) { <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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 max-w-3xl mx-auto px-4 py-16"> diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index ca553a67..08da9fd7 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -28,8 +28,8 @@ export default async function BlogPage() { <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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 max-w-3xl mx-auto px-4 py-16"> diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 29151bd8..3ad99023 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -77,8 +77,8 @@ export default async function ContributorsPage() { <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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"> @@ -138,8 +138,8 @@ export default async function ContributorsPage() { className="group block flex-1" > <div className="relative mx-auto max-w-md"> - <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" /> - <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> + <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"> @@ -268,7 +268,7 @@ export default async function ContributorsPage() { animationDelay: `${index * 100}ms`, }} > - <Card className="h-full bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> + <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"> diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx new file mode 100644 index 00000000..151f7f94 --- /dev/null +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -0,0 +1,13 @@ +"use client";
+
+import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher";
+
+export default function EditorLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ useGlobalPrefetcher();
+
+ 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 26fdcf8a..cae8e779 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -32,39 +32,121 @@ export default function Editor() { setPropertiesPanel, } = usePanelStore(); - const { activeProject, loadProject, createNewProject } = useProjectStore(); + 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; + 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) { - handledProjectIds.current.add(projectId); + // Check if component was unmounted during async operation + if (isCancelled) { + return; + } - const newProjectId = await createNewProject("Untitled Project"); - router.replace(`/editor/${newProjectId}`); - 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(); - }, [projectId, activeProject?.id, loadProject, createNewProject, router]); + + // Cleanup function to cancel async operations + return () => { + isCancelled = true; + isInitializingRef.current = false; + }; + }, [ + projectId, + loadProject, + createNewProject, + router, + isInvalidProjectId, + markProjectIdAsInvalid, + ]); return ( <EditorProvider> @@ -85,7 +167,7 @@ export default function Editor() { {/* Main content area */} <ResizablePanelGroup direction="horizontal" - className="h-full w-full gap-[0.19rem] px-2" + className="h-full w-full gap-[0.19rem] px-3" > {/* Tools Panel */} <ResizablePanel @@ -93,7 +175,7 @@ export default function Editor() { minSize={15} maxSize={40} onResize={setToolsPanel} - className="min-w-0" + className="min-w-0 rounded-sm" > <MediaPanel /> </ResizablePanel> @@ -117,7 +199,7 @@ export default function Editor() { minSize={15} maxSize={40} onResize={setPropertiesPanel} - className="min-w-0" + className="min-w-0 rounded-sm" > <PropertiesPanel /> </ResizablePanel> @@ -132,7 +214,7 @@ export default function Editor() { minSize={15} maxSize={70} onResize={setTimeline} - className="min-h-0 px-2 pb-2" + className="min-h-0 px-3 pb-3" > <Timeline /> </ResizablePanel> diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2f7158cc..ca78ee26 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,82 +1,107 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 1rem; - --sidebar-background: 0 0% 100%; - --sidebar-foreground: 0 0% 3.9%; - --sidebar-primary: 0 0% 9%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 96.1%; - --sidebar-accent-foreground: 0 0% 9%; - --sidebar-border: 0 0% 89.8%; - --sidebar-ring: 0 0% 3.9%; - } - .dark { - --background: 0 0% 4%; - --foreground: 0 0% 89%; - --card: 0 0% 14.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 14.9%; - --popover-foreground: 0 0% 98%; - --primary: 180 95% 40%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 100% 60%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 17%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 0 0% 3.9%; - --sidebar-foreground: 0 0% 98%; - --sidebar-primary: 0 0% 98%; - --sidebar-primary-foreground: 0 0% 9%; - --sidebar-accent: 0 0% 14.9%; - --sidebar-accent-foreground: 0 0% 98%; - --sidebar-border: 0 0% 14.9%; - --sidebar-ring: 0 0% 83.1%; - --panel-background: 0 0% 11%; - --panel-accent: 0 0% 15%; - } +/* Custom variant for dark mode */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Plugins */ +@plugin "@tailwindcss/typography"; +@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; +} +.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%); } @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. + + 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; } @@ -86,3 +111,135 @@ 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; + } + } +} + +@utility scrollbar-hidden { + -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; + } +} + +@utility scrollbar-y-hidden { + -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); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5c1d7520..c5a9bf45 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,8 +14,8 @@ export const metadata = baseMetaData; const protectedRoutes = [ { - path: "/api/waitlist", - method: "POST", + path: "/none", + method: "GET", }, ]; @@ -30,7 +30,7 @@ export default function RootLayout({ <BotIdClient protect={protectedRoutes} /> </head> <body className={`${defaultFont.className} font-sans antialiased`}> - <ThemeProvider attribute="class" forcedTheme="dark"> + <ThemeProvider attribute="class" defaultTheme="dark"> <TooltipProvider> <StorageProvider>{children}</StorageProvider> <Analytics /> diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index b7d700b5..4bb1e023 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -24,8 +24,8 @@ export default function PrivacyPage() { <Header /> <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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"> @@ -47,7 +47,7 @@ export default function PrivacyPage() { have any questions. </p> </div> - <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30"> + <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"> diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 289bf9c1..797cb544 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -142,7 +142,7 @@ export default function ProjectsPage() { href="/" className="flex items-center gap-1 hover:text-muted-foreground transition-colors" > - <ChevronLeft className="!size-5 shrink-0" /> + <ChevronLeft className="size-5! shrink-0" /> <span className="text-sm font-medium">Back</span> </Link> <div className="block md:hidden"> @@ -153,7 +153,7 @@ export default function ProjectsPage() { size="sm" onClick={handleCancelSelection} > - <X className="!size-4" /> + <X className="size-4!" /> Cancel </Button> {selectedProjects.size > 0 && ( @@ -162,7 +162,7 @@ export default function ProjectsPage() { size="sm" onClick={() => setIsBulkDeleteDialogOpen(true)} > - <Trash2 className="!size-4" /> + <Trash2 className="size-4!" /> Delete ({selectedProjects.size}) </Button> )} @@ -192,7 +192,7 @@ export default function ProjectsPage() { {isSelectionMode ? ( <div className="flex items-center gap-2"> <Button variant="outline" onClick={handleCancelSelection}> - <X className="!size-4" /> + <X className="size-4!" /> Cancel </Button> {selectedProjects.size > 0 && ( @@ -200,7 +200,7 @@ export default function ProjectsPage() { variant="destructive" onClick={() => setIsBulkDeleteDialogOpen(true)} > - <Trash2 className="!size-4" /> + <Trash2 className="size-4!" /> Delete Selected ({selectedProjects.size}) </Button> )} @@ -399,7 +399,7 @@ function ProjectCard({ > {isSelectionMode && ( <div className="absolute top-3 left-3 z-10"> - <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> + <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-xs border flex items-center justify-center"> <Checkbox checked={isSelected} onCheckedChange={(checked) => @@ -426,7 +426,7 @@ function ProjectCard({ /> ) : ( <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + <Video className="h-12 w-12 shrink-0 text-muted-foreground" /> </div> )} </div> @@ -501,7 +501,7 @@ function ProjectCard({ <div className="space-y-1"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> + <Calendar className="size-4!" /> <span>Created {formatDate(project.createdAt)}</span> </div> </div> @@ -543,7 +543,7 @@ function ProjectCard({ function CreateButton({ onClick }: { onClick?: () => void }) { return ( <Button className="flex" onClick={onClick}> - <Plus className="!size-4" /> + <Plus className="size-4!" /> <span className="text-sm font-medium">New project</span> </Button> ); diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 5c003cf0..b26da5aa 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -122,8 +122,8 @@ export default function RoadmapPage() { <Header /> <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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"> @@ -148,7 +148,7 @@ export default function RoadmapPage() { {roadmapItems.map((item, index) => ( <div key={index} className="relative"> <div className="flex items-start gap-2"> - <span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]"> + <span className="text-lg font-medium text-muted-foreground select-none leading-normal"> {index + 1}. </span> <div className="flex-1 pt-[2px]"> @@ -156,13 +156,13 @@ export default function RoadmapPage() { <h3 className="font-medium text-lg">{item.title}</h3> <Badge className={cn("shadow-none", { - "!bg-green-500 text-white": + "bg-green-500! text-white": item.status.type === "complete", - "!bg-yellow-500 text-white": + "bg-yellow-500! text-white": item.status.type === "pending", - "!bg-blue-500 text-white": + "bg-blue-500! text-white": item.status.type === "info", - "!bg-foreground/10 text-accent-foreground": + "bg-foreground/10! text-accent-foreground": item.status.type === "default", })} > diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 7d1fda7f..ee202d8b 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -24,8 +24,8 @@ export default function TermsPage() { <Header /> <main className="relative"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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"> @@ -47,7 +47,7 @@ export default function TermsPage() { editor. </p> </div> - <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30"> + <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"> diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx index 7fe6df66..b05860a6 100644 --- a/apps/web/src/app/why-not-capcut/page.tsx +++ b/apps/web/src/app/why-not-capcut/page.tsx @@ -7,8 +7,8 @@ export default function WhyNotCapcut() { <main className="relative mt-12"> <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> + <div 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"> |
