From c8980de41a64922eb27e3b43be1ba1dcfe5a274c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 17:28:01 +0200 Subject: [PATCH 01/10] refactor: remove all waitlist-related code --- apps/web/src/app/api/waitlist/route.ts | 105 ------------------- apps/web/src/app/api/waitlist/token/route.ts | 61 ----------- apps/web/src/app/layout.tsx | 4 +- apps/web/src/lib/rate-limit.ts | 6 +- apps/web/src/lib/waitlist.ts | 13 --- packages/db/src/schema.ts | 8 -- 6 files changed, 5 insertions(+), 192 deletions(-) delete mode 100644 apps/web/src/app/api/waitlist/route.ts delete mode 100644 apps/web/src/app/api/waitlist/token/route.ts delete mode 100644 apps/web/src/lib/waitlist.ts 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 { - 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/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", }, ]; diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index e1996cf5..92f70805 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -8,9 +8,9 @@ const redis = new Redis({ token: env.UPSTASH_REDIS_REST_TOKEN, }); -export const waitlistRateLimit = new Ratelimit({ +export const baseRateLimit = new Ratelimit({ redis, - limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute + limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute analytics: true, - prefix: "waitlist-rate-limit", + prefix: "rate-limit", }); diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts deleted file mode 100644 index 43a620b0..00000000 --- a/apps/web/src/lib/waitlist.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { db, sql, waitlist } from "@opencut/db"; - -export async function getWaitlistCount() { - try { - const result = await db - .select({ count: sql`count(*)` }) - .from(waitlist); - return result[0]?.count || 0; - } catch (error) { - console.error("Failed to fetch waitlist count:", error); - return 0; - } -} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index bb92a14e..c32d6c2a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -57,11 +57,3 @@ export const verifications = pgTable("verifications", { () => /* @__PURE__ */ new Date() ), }).enableRLS(); - -export const waitlist = pgTable("waitlist", { - id: text("id").primaryKey(), - email: text("email").notNull().unique(), - createdAt: timestamp("created_at") - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), -}).enableRLS(); From 5e2b07e518629a0fefa95ca5f99b60b7dab6bf7c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 17:33:23 +0200 Subject: [PATCH 02/10] feat: better auth rate limiting --- packages/auth/src/keys.ts | 4 ++++ packages/auth/src/server.ts | 27 +++++++++++++++++++++++++-- tsconfig.json | 4 +++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/auth/src/keys.ts b/packages/auth/src/keys.ts index 5dfaf0ea..3ac528f4 100644 --- a/packages/auth/src/keys.ts +++ b/packages/auth/src/keys.ts @@ -5,6 +5,8 @@ export const keys = () => createEnv({ server: { BETTER_AUTH_SECRET: z.string(), + UPSTASH_REDIS_REST_URL: z.string().url(), + UPSTASH_REDIS_REST_TOKEN: z.string(), }, client: { NEXT_PUBLIC_BETTER_AUTH_URL: z.string().url(), @@ -12,5 +14,7 @@ export const keys = () => runtimeEnv: { NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, }, }); diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 02d147e0..84b91b39 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,9 +1,20 @@ -import { betterAuth } from "better-auth"; +import { betterAuth, RateLimit } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "@opencut/db"; import { keys } from "./keys"; +import { Redis } from "@upstash/redis"; -const { NEXT_PUBLIC_BETTER_AUTH_URL, BETTER_AUTH_SECRET } = keys(); +const { + NEXT_PUBLIC_BETTER_AUTH_URL, + BETTER_AUTH_SECRET, + UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN, +} = keys(); + +const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, +}); export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -19,6 +30,18 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + rateLimit: { + storage: "secondary-storage", + customStorage: { + get: async (key) => { + const value = await redis.get(key); + return value as RateLimit | undefined; + }, + set: async (key, value) => { + await redis.set(key, value); + }, + }, + }, baseURL: NEXT_PUBLIC_BETTER_AUTH_URL, appName: "OpenCut", trustedOrigins: ["http://localhost:3000"], diff --git a/tsconfig.json b/tsconfig.json index 9647394a..04ab0f5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { - "strictNullChecks": true + "strictNullChecks": true, + "moduleResolution": "bundler", + "module": "esnext" } } From 02a50d12de10849605f11ce0ab0a41e2e1c112ba Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 19:22:06 +0200 Subject: [PATCH 03/10] feat: empty state on properties panel --- apps/web/src/app/editor/[project_id]/page.tsx | 2 +- .../editor/properties-panel/index.tsx | 109 +++++------------- apps/web/src/components/icons.tsx | 22 ---- 3 files changed, 29 insertions(+), 104 deletions(-) diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 03579144..cae8e779 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -199,7 +199,7 @@ export default function Editor() { minSize={15} maxSize={40} onResize={setPropertiesPanel} - className="min-w-0" + className="min-w-0 rounded-sm" > diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index f1b3f6a3..6c79327e 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -1,95 +1,22 @@ "use client"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { useMediaStore } from "@/stores/media-store"; -import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; -import { Label } from "../../ui/label"; import { ScrollArea } from "../../ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../ui/select"; import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; import { TextProperties } from "./text-properties"; +import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); const { selectedElements, tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - if (!isNaN(fps) && fps > 0) { - updateProjectFps(fps); - } - }; - - const emptyView = ( -
- {/* Media Properties */} -
- - - Name: - - - {activeProject?.name || ""} - - - - - Aspect ratio: - - - {getDisplayName()} - - - - - Resolution: - - - {`${canvasSize.width} × ${canvasSize.height}`} - - -
- - -
-
-
- ); - return ( - - {selectedElements.length > 0 - ? selectedElements.map(({ trackId, elementId }) => { + <> + {selectedElements.length > 0 ? ( + + {selectedElements.map(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); const element = track?.elements.find((e) => e.id === elementId); @@ -116,8 +43,28 @@ export function PropertiesPanel() { ); } return null; - }) - : emptyView} - + })} + + ) : ( + + )} + + ); +} + +function EmptyView() { + return ( +
+ +
+

It’s empty here

+

+ Click an element on the timeline to edit its properties +

+
+
); } diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 065a123d..43f953e8 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -163,25 +163,3 @@ export function DataBuddyIcon({ ); } - -export function SquareSlashIcon({ - className, - size = 24, -}: { - className?: string; - size?: number; -}) { - return ( - - - - - ); -} From f50e250ec62d4354d918e08fbb1cb1eb10df8966 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sun, 3 Aug 2025 12:43:17 +0300 Subject: [PATCH 04/10] Update CODE_OF_CONDUCT.md (#512) --- .github/CODE_OF_CONDUCT.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index b7277d26..b6d67872 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -59,8 +59,7 @@ representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. +reported to the community leaders responsible for enforcement at our discord server. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -87,4 +86,4 @@ version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. [homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html \ No newline at end of file +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html From c0651fec193bcea9a1764ca8435d7191c5d3980d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 3 Aug 2025 12:29:06 +0200 Subject: [PATCH 05/10] feat: replace scroll-area from radix with one that actually works and doesn't cause layout issues --- apps/web/src/app/globals.css | 2 +- apps/web/src/components/ui/scroll-area.tsx | 55 ++++------------------ 2 files changed, 11 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b25f2c1e..a6b86ae4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -229,7 +229,7 @@ @utility scrollbar-thin { &::-webkit-scrollbar { - width: 8px; + width: 6px; height: 8px; } &::-webkit-scrollbar-track { diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index d5aaf2ee..1fc227b0 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,53 +1,18 @@ -"use client"; - import * as React from "react"; -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; - -import { cn } from "../../lib/utils"; +import { cn } from "@/lib/utils"; const ScrollArea = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - type?: "auto" | "always" | "scroll" | "hover"; - showHorizontalScrollbar?: boolean; - } ->(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => ( - +>(({ className, children, ...props }, ref) => ( +
- - {children} - - - {showHorizontalScrollbar && } - - + {children} +
)); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; +ScrollArea.displayName = "ScrollArea"; -const ScrollBar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, orientation = "vertical", ...props }, ref) => ( - - - -)); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; - -export { ScrollArea, ScrollBar }; +export { ScrollArea }; From b52a9ca3e27782f84c2a916120e82e81e48cd7d5 Mon Sep 17 00:00:00 2001 From: Eshan Das Date: Sun, 3 Aug 2025 17:14:23 +0530 Subject: [PATCH 06/10] fix: preserve line breaks in text element rendering (#461) - Change whiteSpace from 'nowrap' to 'pre-wrap' in preview panel - Allows multi-line text content to display correctly - Maintains existing text wrapping behavior --- apps/web/src/components/editor/preview-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 58cc5d5b..b9c03382 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -381,7 +381,7 @@ export function PreviewPanel() { textDecoration: element.textDecoration, padding: "4px 8px", borderRadius: "2px", - whiteSpace: "nowrap", + whiteSpace: "pre-wrap", // Fallback for system fonts that don't have classes ...(fontClassName === "" && { fontFamily: element.fontFamily }), }} From 48b694bbd875740bbdb350001bb688c2fbf4ad1f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 01:30:33 +0200 Subject: [PATCH 07/10] feat: sound effects functionality with freesound's api --- apps/web/.env.example | 6 +- apps/web/src/app/api/sounds/search/route.ts | 254 ++++++++++ .../src/app/editor/[project_id]/layout.tsx | 13 + apps/web/src/app/globals.css | 2 +- .../components/editor/media-panel/index.tsx | 4 +- .../components/editor/media-panel/store.ts | 6 +- .../editor/media-panel/views/audio.tsx | 19 - .../editor/media-panel/views/sounds.tsx | 463 ++++++++++++++++++ .../src/components/editor/timeline/index.tsx | 9 +- .../components/providers/global-prefetcher.ts | 78 +++ apps/web/src/components/ui/input.tsx | 62 ++- apps/web/src/env.ts | 4 + apps/web/src/hooks/use-sound-search.ts | 155 ++++++ apps/web/src/lib/storage/storage-service.ts | 92 ++++ apps/web/src/lib/storage/types.ts | 1 + apps/web/src/stores/sounds-store.ts | 273 +++++++++++ apps/web/src/stores/timeline-store.ts | 22 +- apps/web/src/types/sounds.ts | 39 ++ 18 files changed, 1457 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/api/sounds/search/route.ts create mode 100644 apps/web/src/app/editor/[project_id]/layout.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/audio.tsx create mode 100644 apps/web/src/components/editor/media-panel/views/sounds.tsx create mode 100644 apps/web/src/components/providers/global-prefetcher.ts create mode 100644 apps/web/src/hooks/use-sound-search.ts create mode 100644 apps/web/src/stores/sounds-store.ts create mode 100644 apps/web/src/types/sounds.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index b21f9a92..af84fd77 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,4 +16,8 @@ UPSTASH_REDIS_REST_TOKEN=example_token # Marble Blog MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file +NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + +# Freesound (generate at https://freesound.org/apiv2/apply/) +FREESOUND_CLIENT_ID=... +FREESOUND_API_KEY=... \ No newline at end of file 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..15954140 --- /dev/null +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -0,0 +1,254 @@ +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), +}); + +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, + } = 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 *]`); + 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/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
{children}
; +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a6b86ae4..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%); diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 4d80b642..70eb69dd 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -4,7 +4,7 @@ import { TabBar } from "./tabbar"; import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; -import { AudioView } from "./views/audio"; +import { SoundsView } from "./views/sounds"; import { Separator } from "@/components/ui/separator"; import { SettingsView } from "./views/settings"; @@ -13,7 +13,7 @@ export function MediaPanel() { const viewMap: Record = { media: , - audio: , + sounds: , text: , stickers: (
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 577417a9..e1212030 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -15,7 +15,7 @@ import { create } from "zustand"; export type Tab = | "media" - | "audio" + | "sounds" | "text" | "stickers" | "effects" @@ -30,9 +30,9 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { icon: VideoIcon, label: "Media", }, - audio: { + sounds: { icon: MusicIcon, - label: "Audio", + label: "Sounds", }, text: { icon: TypeIcon, diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx deleted file mode 100644 index 0d3c3f1e..00000000 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function AudioView() { - const [search, setSearch] = useState(""); - return ( -
- setSearch(e.target.value)} - /> -
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx new file mode 100644 index 00000000..306b81d3 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { useState, useMemo, useRef, useEffect } from "react"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PlayIcon, PauseIcon, HeartIcon, PlusIcon } from "lucide-react"; +import { useSoundsStore } from "@/stores/sounds-store"; +import { useSoundSearch } from "@/hooks/use-sound-search"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + PropertyGroup, + PropertyItem, + PropertyItemValue, +} from "@/components/editor/properties-panel/property-item"; + +export function SoundsView() { + return ( +
+ +
+ + Sound effects + Songs + Saved + +
+ + + + + + + + + + +
+
+ ); +} + +function SoundEffectsView() { + const { + topSoundEffects, + isLoading, + searchQuery, + setSearchQuery, + scrollPosition, + setScrollPosition, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + } = useSoundsStore(); + const { + results: searchResults, + isLoading: isSearching, + loadMore, + hasNextPage, + isLoadingMore, + } = useSoundSearch(searchQuery); + + // Audio playback state + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + null + ); + + // Scroll position persistence + const scrollAreaRef = useRef(null); + + // Load saved sounds and restore scroll position when component mounts + useEffect(() => { + loadSavedSounds(); + + if (scrollAreaRef.current && scrollPosition > 0) { + const timeoutId = setTimeout(() => { + scrollAreaRef.current?.scrollTo({ top: scrollPosition }); + }, 100); // Small delay to ensure content is rendered + + return () => clearTimeout(timeoutId); + } + }, []); // Only run on mount + + // Track scroll position changes and handle infinite scroll + const handleScroll = (event: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + setScrollPosition(scrollTop); + + // Trigger loadMore when scrolled to within 200px of bottom + const nearBottom = scrollTop + clientHeight >= scrollHeight - 200; + if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) { + loadMore(); + } + }; + + // Use your existing design, just swap the data source + const displayedSounds = useMemo(() => { + const sounds = searchQuery ? searchResults : topSoundEffects; + return sounds; + }, [searchQuery, searchResults, topSoundEffects]); + + const playSound = (sound: SoundEffect) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + // Stop previous sound + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", (e) => { + setPlayingId(null); + }); + audio.play().catch((error) => { + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + return ( +
+ setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + +
+ +
+ {isLoading && !searchQuery && ( +
+ Loading sounds... +
+ )} + {isSearching && searchQuery && ( +
Searching...
+ )} + {displayedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => toggleSavedSound(sound)} + /> + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( +
+ {searchQuery ? "No sounds found" : "No sounds available"} +
+ )} + {isLoadingMore && ( +
+ Loading more sounds... +
+ )} +
+
+
+
+ ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + clearSavedSounds, + } = useSoundsStore(); + + // Audio playback state + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + null + ); + + // Clear confirmation dialog state + const [showClearDialog, setShowClearDialog] = useState(false); + + // Load saved sounds when tab becomes active + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + const playSound = (sound: SavedSound) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + // Stop previous sound + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", (e) => { + setPlayingId(null); + }); + audio.play().catch((error) => { + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + // Convert SavedSound to SoundEffect for compatibility with AudioItem + const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({ + id: savedSound.id, + name: savedSound.name, + description: "", + url: "", + previewUrl: savedSound.previewUrl, + downloadUrl: savedSound.downloadUrl, + duration: savedSound.duration, + filesize: 0, + type: "audio", + channels: 0, + bitrate: 0, + bitdepth: 0, + samplerate: 0, + username: savedSound.username, + tags: savedSound.tags, + license: savedSound.license, + created: savedSound.savedAt, + downloads: 0, + rating: 0, + ratingCount: 0, + }); + + if (isLoadingSavedSounds) { + return ( +
+
+ Loading saved sounds... +
+
+ ); + } + + if (savedSoundsError) { + return ( +
+
+ Error: {savedSoundsError} +
+
+ ); + } + + if (savedSounds.length === 0) { + return ( +
+ +
+

No saved sounds

+

+ Click the heart icon on any sound to save it here +

+
+
+ ); + } + + return ( +
+
+

+ {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} +

+ + + + + + + Clear all saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {savedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => + toggleSavedSound(convertToSoundEffect(sound)) + } + /> + ))} +
+
+
+
+ ); +} + +function SongsView() { + return
Songs
; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: () => void; + isSaved: boolean; + onToggleSaved: () => void; +} + +function AudioItem({ + sound, + isPlaying, + onPlay, + isSaved, + onToggleSaved, +}: AudioItemProps) { + const { addSoundToTimeline } = useSoundsStore(); + + const handleClick = () => { + onPlay(); + }; + + const handleSaveClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleSaved(); + }; + + const handleAddToTimeline = async (e: React.MouseEvent) => { + e.stopPropagation(); + await addSoundToTimeline(sound); + }; + + return ( +
+
+
+ {isPlaying ? ( + + ) : ( + + )} +
+ +
+

{sound.name}

+ + {sound.username} + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 8f41fbf3..7a2cc7f2 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { ScrollArea } from "../../ui/scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "../../ui/button"; import { Scissors, @@ -746,12 +746,7 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - +
{ + if (hasLoaded) return; + + let ignore = false; + + const prefetchTopSounds = async () => { + try { + if (!ignore) { + setLoading(true); + setError(null); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads" + ); + + if (!ignore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects(data.results); + setHasLoaded(true); + + // Set pagination state for top sounds + setCurrentPage(1); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } + } catch (error) { + if (!ignore) { + console.error("Failed to prefetch top sounds:", error); + setError( + error instanceof Error ? error.message : "Failed to load sounds" + ); + } + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(prefetchTopSounds, 100); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index d4cd57a1..4fb0f5b4 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "./button"; @@ -7,39 +7,91 @@ import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; + showClearIcon?: boolean; + onClear?: () => void; } const Input = React.forwardRef( ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, + { + className, + type, + showPassword, + onShowPasswordChange, + showClearIcon, + onClear, + value, + onFocus, + onBlur, + ...props + }, ref ) => { + const [isFocused, setIsFocused] = React.useState(false); + const isPassword = type === "password"; const showPasswordToggle = isPassword && onShowPasswordChange; + const showClear = + showClearIcon && + onClear && + value && + String(value).length > 0 && + isFocused; const inputType = isPassword && showPassword ? "text" : type; + const hasIcons = showPasswordToggle || showClear; + const iconCount = Number(showPasswordToggle) + Number(showClear); + const paddingRight = + iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; + return ( -
+
{ + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} {...props} /> + {showClear && ( + + )} {showPasswordToggle && ( + + + + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+ +
{ + e.preventDefault(); + }} {...props} > - + {children} + - {children} )); + DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 4fb0f5b4..440191f2 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -9,6 +9,7 @@ interface InputProps extends React.ComponentProps<"input"> { onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; onClear?: () => void; + containerClassName?: string; } const Input = React.forwardRef( @@ -16,6 +17,7 @@ const Input = React.forwardRef( { className, type, + containerClassName, showPassword, onShowPasswordChange, showClearIcon, @@ -45,7 +47,7 @@ const Input = React.forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
void; + // Search state searchQuery: string; searchResults: SoundEffect[]; @@ -72,6 +76,11 @@ export const useSoundsStore = create((set, get) => ({ isLoading: false, error: null, hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, // Search state searchQuery: "", From 4cd8100b6b68b947404c13bf41f56847b4df3d58 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 03:13:57 +0200 Subject: [PATCH 10/10] fix: add missing env variables in turbo.json --- turbo.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index cb4c9f9f..5ca2279a 100644 --- a/turbo.json +++ b/turbo.json @@ -8,7 +8,10 @@ "DATABASE_URL", "BETTER_AUTH_SECRET", "UPSTASH_REDIS_REST_URL", - "UPSTASH_REDIS_REST_TOKEN" + "UPSTASH_REDIS_REST_TOKEN", + "MARBLE_WORKSPACE_KEY", + "FREESOUND_CLIENT_ID", + "FREESOUND_API_KEY" ] }, "check-types": {