summaryrefslogtreecommitdiff
path: root/apps/web/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/app')
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts265
-rw-r--r--apps/web/src/app/api/waitlist/route.ts105
-rw-r--r--apps/web/src/app/api/waitlist/token/route.ts61
-rw-r--r--apps/web/src/app/editor/[project_id]/layout.tsx13
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx75
-rw-r--r--apps/web/src/app/globals.css4
-rw-r--r--apps/web/src/app/layout.tsx4
7 files changed, 282 insertions, 245 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/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 9e3d1b9e..f1252a44 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -237,81 +237,6 @@ export default function Editor() {
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
- defaultSize={100 - propertiesPanel}
- minSize={60}
- className="min-w-0 min-h-0"
- >
- <ResizablePanelGroup
- direction="vertical"
- className="h-full w-full gap-[0.18rem]"
- >
- <ResizablePanel
- defaultSize={mainContent}
- minSize={30}
- maxSize={85}
- onResize={setMainContent}
- className="min-h-0"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem]"
- >
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0 rounded-sm"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : activePreset === "vertical-preview" ? (
- <ResizablePanelGroup
- key={`vertical-preview-${activePreset}-${resetCounter}`}
- direction="horizontal"
- className="h-full w-full gap-[0.18rem] px-3 pb-3"
- >
- <ResizablePanel
defaultSize={100 - previewPanel}
minSize={60}
className="min-w-0 min-h-0"
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index b25f2c1e..ca78ee26 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -23,7 +23,7 @@
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%);
--accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0 100% 40%);
+ --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--input: hsl(0 0% 85.1%);
@@ -229,7 +229,7 @@
@utility scrollbar-thin {
&::-webkit-scrollbar {
- width: 8px;
+ width: 6px;
height: 8px;
}
&::-webkit-scrollbar-track {
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index a64c4440..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",
},
];