diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index e77d3c5a0..000000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,33 +0,0 @@ -# Environment Variables Example -# Copy this file to .env.local and update the values as needed - -DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" - -# Better Auth -NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 -BETTER_AUTH_SECRET=your-secret-key-here - -# Development Environment -NODE_ENV=development - -# Redis -UPSTASH_REDIS_REST_URL=http://localhost:8079 -UPSTASH_REDIS_REST_TOKEN=example_token - -# Marble Blog -MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com - -# Freesound (generate at https://freesound.org/apiv2/apply/) -FREESOUND_CLIENT_ID=... -FREESOUND_API_KEY=... - -# Cloudflare R2 (for auto-captions/transcription) -# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens -CLOUDFLARE_ACCOUNT_ID=your-account-id -R2_ACCESS_KEY_ID=your-access-key-id -R2_SECRET_ACCESS_KEY=your-secret-access-key -R2_BUCKET_NAME=opencut-transcription - -# Modal transcription endpoint (from modal deploy transcription.py) -MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run \ No newline at end of file diff --git a/apps/web/migrations/meta/0003_snapshot.json b/apps/web/migrations/meta/0003_snapshot.json index 2c5d986ea..98f4ce9b5 100644 --- a/apps/web/migrations/meta/0003_snapshot.json +++ b/apps/web/migrations/meta/0003_snapshot.json @@ -93,12 +93,8 @@ "name": "accounts_user_id_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -145,9 +141,7 @@ "export_waitlist_email_unique": { "name": "export_waitlist_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -213,12 +207,8 @@ "name": "sessions_user_id_users_id_fk", "tableFrom": "sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -228,9 +218,7 @@ "sessions_token_unique": { "name": "sessions_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -292,9 +280,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -362,4 +348,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index c172952eb..92428a76e 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -31,4 +31,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts index dc5b7328f..d8252968b 100644 --- a/apps/web/src/app/api/get-upload-url/route.ts +++ b/apps/web/src/app/api/get-upload-url/route.ts @@ -1,128 +1,128 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { AwsClient } from "aws4fetch"; -import { nanoid } from "nanoid"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const uploadRequestSchema = z.object({ - fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { - errorMap: () => ({ - message: "File extension must be wav, mp3, m4a, or flac", - }), - }), -}); - -const apiResponseSchema = z.object({ - uploadUrl: z.string().url(), - fileName: z.string().min(1), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = uploadRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { fileExtension } = validationResult.data; - - // Initialize R2 client - const client = new AwsClient({ - accessKeyId: env.R2_ACCESS_KEY_ID, - secretAccessKey: env.R2_SECRET_ACCESS_KEY, - }); - - // Generate unique filename with timestamp - const timestamp = Date.now(); - const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; - - // Create presigned URL - const url = new URL( - `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` - ); - - url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry - - const signed = await client.sign(new Request(url, { method: "PUT" }), { - aws: { signQuery: true }, - }); - - if (!signed.url) { - throw new Error("Failed to generate presigned URL"); - } - - // Prepare and validate response - const responseData = { - uploadUrl: signed.url, - fileName, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error generating upload URL:", error); - return NextResponse.json( - { - error: "Failed to generate upload URL", - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { AwsClient } from "aws4fetch"; +import { nanoid } from "nanoid"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { isTranscriptionConfigured } from "@/lib/transcription-utils"; + +const uploadRequestSchema = z.object({ + fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { + errorMap: () => ({ + message: "File extension must be wav, mp3, m4a, or flac", + }), + }), +}); + +const apiResponseSchema = z.object({ + uploadUrl: z.string().url(), + fileName: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + // Check transcription configuration + const transcriptionCheck = isTranscriptionConfigured(); + if (!transcriptionCheck.configured) { + console.error( + "Missing environment variables:", + JSON.stringify(transcriptionCheck.missingVars) + ); + + return NextResponse.json( + { + error: "Transcription not configured", + message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, + }, + { status: 503 } + ); + } + + // Parse and validate request body + const rawBody = await request.json().catch(() => null); + if (!rawBody) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const validationResult = uploadRequestSchema.safeParse(rawBody); + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { fileExtension } = validationResult.data; + + // Initialize R2 client + const client = new AwsClient({ + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + }); + + // Generate unique filename with timestamp + const timestamp = Date.now(); + const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; + + // Create presigned URL + const url = new URL( + `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` + ); + + url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry + + const signed = await client.sign(new Request(url, { method: "PUT" }), { + aws: { signQuery: true }, + }); + + if (!signed.url) { + throw new Error("Failed to generate presigned URL"); + } + + // Prepare and validate response + const responseData = { + uploadUrl: signed.url, + fileName, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error generating upload URL:", error); + return NextResponse.json( + { + error: "Failed to generate upload URL", + message: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index c89bc76c6..8ca4ba414 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,265 +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 } - ); - } -} +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/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts index 9a497f65e..60de5f967 100644 --- a/apps/web/src/app/api/transcribe/route.ts +++ b/apps/web/src/app/api/transcribe/route.ts @@ -1,189 +1,189 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const transcribeRequestSchema = z.object({ - filename: z.string().min(1, "Filename is required"), - language: z.string().optional().default("auto"), - decryptionKey: z.string().min(1, "Decryption key is required").optional(), - iv: z.string().min(1, "IV is required").optional(), -}); - -const modalResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -const apiResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - const origin = request.headers.get("origin"); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = transcribeRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { filename, language, decryptionKey, iv } = validationResult.data; - - // Prepare request body for Modal - const modalRequestBody: any = { - filename, - language, - }; - - // Add encryption parameters if provided (zero-knowledge) - if (decryptionKey && iv) { - modalRequestBody.decryptionKey = decryptionKey; - modalRequestBody.iv = iv; - } - - // Call Modal transcription service - const response = await fetch(env.MODAL_TRANSCRIPTION_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(modalRequestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Modal API error:", response.status, errorText); - - let errorMessage = "Transcription service unavailable"; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.error || errorMessage; - } catch { - // Use default message if parsing fails - } - - return NextResponse.json( - { - error: errorMessage, - message: "Failed to process transcription request", - }, - { status: response.status >= 500 ? 502 : response.status } - ); - } - - const rawResult = await response.json(); - console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2)); - - // Validate Modal response - const modalValidation = modalResponseSchema.safeParse(rawResult); - if (!modalValidation.success) { - console.error("Invalid Modal API response:", modalValidation.error); - return NextResponse.json( - { error: "Invalid response from transcription service" }, - { status: 502 } - ); - } - - const result = modalValidation.data; - - // Prepare and validate API response - const responseData = { - text: result.text, - segments: result.segments, - language: result.language, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Transcription API error:", error); - return NextResponse.json( - { - error: "Internal server error", - message: "An unexpected error occurred during transcription", - }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { isTranscriptionConfigured } from "@/lib/transcription-utils"; + +const transcribeRequestSchema = z.object({ + filename: z.string().min(1, "Filename is required"), + language: z.string().optional().default("auto"), + decryptionKey: z.string().min(1, "Decryption key is required").optional(), + iv: z.string().min(1, "IV is required").optional(), +}); + +const modalResponseSchema = z.object({ + text: z.string(), + segments: z.array( + z.object({ + id: z.number(), + seek: z.number(), + start: z.number(), + end: z.number(), + text: z.string(), + tokens: z.array(z.number()), + temperature: z.number(), + avg_logprob: z.number(), + compression_ratio: z.number(), + no_speech_prob: z.number(), + }) + ), + language: z.string(), +}); + +const apiResponseSchema = z.object({ + text: z.string(), + segments: z.array( + z.object({ + id: z.number(), + seek: z.number(), + start: z.number(), + end: z.number(), + text: z.string(), + tokens: z.array(z.number()), + temperature: z.number(), + avg_logprob: z.number(), + compression_ratio: z.number(), + no_speech_prob: z.number(), + }) + ), + language: z.string(), +}); + +export async function POST(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + const origin = request.headers.get("origin"); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + // Check transcription configuration + const transcriptionCheck = isTranscriptionConfigured(); + if (!transcriptionCheck.configured) { + console.error( + "Missing environment variables:", + JSON.stringify(transcriptionCheck.missingVars) + ); + + return NextResponse.json( + { + error: "Transcription not configured", + message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, + }, + { status: 503 } + ); + } + + // Parse and validate request body + const rawBody = await request.json().catch(() => null); + if (!rawBody) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const validationResult = transcribeRequestSchema.safeParse(rawBody); + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { filename, language, decryptionKey, iv } = validationResult.data; + + // Prepare request body for Modal + const modalRequestBody: any = { + filename, + language, + }; + + // Add encryption parameters if provided (zero-knowledge) + if (decryptionKey && iv) { + modalRequestBody.decryptionKey = decryptionKey; + modalRequestBody.iv = iv; + } + + // Call Modal transcription service + const response = await fetch(env.MODAL_TRANSCRIPTION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(modalRequestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Modal API error:", response.status, errorText); + + let errorMessage = "Transcription service unavailable"; + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.error || errorMessage; + } catch { + // Use default message if parsing fails + } + + return NextResponse.json( + { + error: errorMessage, + message: "Failed to process transcription request", + }, + { status: response.status >= 500 ? 502 : response.status } + ); + } + + const rawResult = await response.json(); + console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2)); + + // Validate Modal response + const modalValidation = modalResponseSchema.safeParse(rawResult); + if (!modalValidation.success) { + console.error("Invalid Modal API response:", modalValidation.error); + return NextResponse.json( + { error: "Invalid response from transcription service" }, + { status: 502 } + ); + } + + const result = modalValidation.data; + + // Prepare and validate API response + const responseData = { + text: result.text, + segments: result.segments, + language: result.language, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Transcription API error:", error); + return NextResponse.json( + { + error: "Internal server error", + message: "An unexpected error occurred during transcription", + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts index 0200e255e..f48d0b0e8 100644 --- a/apps/web/src/app/api/waitlist/export/route.ts +++ b/apps/web/src/app/api/waitlist/export/route.ts @@ -1,83 +1,83 @@ -import { NextRequest, NextResponse } from "next/server"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { db, exportWaitlist, eq } from "@opencut/db"; -import { randomUUID } from "crypto"; -import { - exportWaitlistSchema, - exportWaitlistResponseSchema, -} from "@/lib/schemas/waitlist"; - -const requestSchema = exportWaitlistSchema; -const responseSchema = exportWaitlistResponseSchema; - -export async function POST(request: NextRequest) { - try { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json().catch(() => null); - if (!body) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: parsed.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { email } = parsed.data; - - const existing = await db - .select({ id: exportWaitlist.id }) - .from(exportWaitlist) - .where(eq(exportWaitlist.email, email)) - .limit(1); - - if (existing.length > 0) { - const responseData = { success: true, alreadySubscribed: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } - - await db.insert(exportWaitlist).values({ - id: randomUUID(), - email, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const responseData = { success: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } catch (error) { - console.error("Waitlist API error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { baseRateLimit } from "@/lib/rate-limit"; +import { db, exportWaitlist, eq } from "@opencut/db"; +import { randomUUID } from "crypto"; +import { + exportWaitlistSchema, + exportWaitlistResponseSchema, +} from "@/lib/schemas/waitlist"; + +const requestSchema = exportWaitlistSchema; +const responseSchema = exportWaitlistResponseSchema; + +export async function POST(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const body = await request.json().catch(() => null); + if (!body) { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid request parameters", + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { email } = parsed.data; + + const existing = await db + .select({ id: exportWaitlist.id }) + .from(exportWaitlist) + .where(eq(exportWaitlist.email, email)) + .limit(1); + + if (existing.length > 0) { + const responseData = { success: true, alreadySubscribed: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } + + await db.insert(exportWaitlist).values({ + id: randomUUID(), + email, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const responseData = { success: true } as const; + const validated = responseSchema.safeParse(responseData); + if (!validated.success) { + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + return NextResponse.json(validated.data); + } catch (error) { + console.error("Waitlist API error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx index 151f7f94f..189a08994 100644 --- a/apps/web/src/app/editor/[project_id]/layout.tsx +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -1,13 +1,13 @@ -"use client"; - -import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; - -export default function EditorLayout({ - children, -}: { - children: React.ReactNode; -}) { - useGlobalPrefetcher(); - - return
{children}
; -} +"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/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 3d66a3c56..480d4940a 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -221,13 +221,16 @@ export default function ProjectsPage() {
-
+
+ setSearchQuery(e.target.value)} + className="pl-10" />
+
diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx index ff4cde57a..2ea2c39fb 100644 --- a/apps/web/src/components/editor/layout-guide-overlay.tsx +++ b/apps/web/src/components/editor/layout-guide-overlay.tsx @@ -1,27 +1,27 @@ -"use client"; - -import { useEditorStore } from "@/stores/editor-store"; -import Image from "next/image"; - -function TikTokGuide() { - return ( -
- TikTok layout guide -
- ); -} - -export function LayoutGuideOverlay() { - const { layoutGuide } = useEditorStore(); - - if (layoutGuide.platform === null) return null; - if (layoutGuide.platform === "tiktok") return ; - - return null; -} +"use client"; + +import { useEditorStore } from "@/stores/editor-store"; +import Image from "next/image"; + +function TikTokGuide() { + return ( +
+ TikTok layout guide +
+ ); +} + +export function LayoutGuideOverlay() { + const { layoutGuide } = useEditorStore(); + + if (layoutGuide.platform === null) return null; + if (layoutGuide.platform === "tiktok") return ; + + return null; +} diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx index 2cd335359..47ec9b3ba 100644 --- a/apps/web/src/components/editor/media-panel/views/base-view.tsx +++ b/apps/web/src/components/editor/media-panel/views/base-view.tsx @@ -1,67 +1,67 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -interface BaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - tabs?: { - value: string; - label: string; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.RefObject; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - -
{children}
-
- ); -} - -export function BaseView({ - children, - defaultTab, - tabs, - className = "", - ref, -}: BaseViewProps) { - return ( -
- {!tabs || tabs.length === 0 ? ( - {children} - ) : ( - -
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - -
- - {tabs.map((tab) => ( - - {tab.content} - - ))} -
- )} -
- ); -} +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface BaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + tabs?: { + value: string; + label: string; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + +
{children}
+
+ ); +} + +export function BaseView({ + children, + defaultTab, + tabs, + className = "", + ref, +}: BaseViewProps) { + return ( +
+ {!tabs || tabs.length === 0 ? ( + {children} + ) : ( + +
+ + {tabs.map((tab) => ( + + {tab.label} + + ))} + +
+ + {tabs.map((tab) => ( + + {tab.content} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 303eb858e..9cac3e486 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -120,7 +120,7 @@ function SoundEffectsView() { setScrollPosition(scrollTop); handleScroll(event); }; - + const displayedSounds = useMemo(() => { const sounds = searchQuery ? searchResults : topSoundEffects; return sounds; diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 7d3e073e3..2b74b6c42 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -565,7 +565,12 @@ interface StickerItemProps { capSize?: boolean; } -function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItemProps) { +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { const [imageError, setImageError] = useState(false); const [hostIndex, setHostIndex] = useState(0); @@ -601,7 +606,10 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem className="w-full h-full object-contain" style={ capSize - ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)" } + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } : undefined } onError={() => { diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 42c4becc9..80097a4f7 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -14,7 +14,11 @@ import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; +import { + DEFAULT_CANVAS_SIZE, + DEFAULT_FPS, + useProjectStore, +} from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; import { Popover, @@ -363,18 +367,20 @@ export function PreviewPanel() { style={{ left: `${ 50 + - ((dragState.isDragging && dragState.elementId === element.id - ? dragState.currentX - : element.x) / - canvasSize.width) * + ( + (dragState.isDragging && dragState.elementId === element.id + ? dragState.currentX + : element.x) / canvasSize.width + ) * 100 }%`, top: `${ 50 + - ((dragState.isDragging && dragState.elementId === element.id - ? dragState.currentY - : element.y) / - canvasSize.height) * + ( + (dragState.isDragging && dragState.elementId === element.id + ? dragState.currentY + : element.y) / canvasSize.height + ) * 100 }%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`, diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 744c2be50..11ac87e41 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -247,7 +247,10 @@ export function TextProperties({ checked={element.backgroundColor === "transparent"} onCheckedChange={handleTransparentToggle} /> -
diff --git a/apps/web/src/components/export-button.tsx b/apps/web/src/components/export-button.tsx index c325db5ce..a7fab1179 100644 --- a/apps/web/src/components/export-button.tsx +++ b/apps/web/src/components/export-button.tsx @@ -170,7 +170,9 @@ function ExportDialog({ aria-live="polite" className={cn( "text-xs", - serverMessage.type === "success" ? "text-green-600" : "text-red-600" + serverMessage.type === "success" + ? "text-green-600" + : "text-red-600" )} > {serverMessage.text} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 0087502e4..70f83bf32 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -36,10 +36,10 @@ export function Footer() { {/* Brand Section */}
- OpenCut diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index b24687a67..55707e1ae 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -181,15 +181,49 @@ export function SocialsIcon({ className={className} > - - - - + + + + - - - - + + + + ); } @@ -230,4 +264,4 @@ export function TransitionUpIcon({ /> ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index dd6a500ba..159766deb 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -242,4 +242,4 @@ function EditableShortcutKey({ {children} ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/language-select.tsx b/apps/web/src/components/language-select.tsx index 43ed632d6..d66aed647 100644 --- a/apps/web/src/components/language-select.tsx +++ b/apps/web/src/components/language-select.tsx @@ -1,204 +1,204 @@ -import { useState, useRef, useEffect } from "react"; -import { ChevronDown, Globe } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { motion } from "framer-motion"; -import ReactCountryFlag from "react-country-flag"; - -export interface Language { - code: string; - name: string; - flag?: string; -} - -interface LanguageSelectProps { - selectedCountry: string; - onSelect: (country: string) => void; - containerRef: React.RefObject; - languages: Language[]; -} - -function FlagPreloader({ languages }: { languages: Language[] }) { - return ( -
- {languages.map((language) => ( - - ))} -
- ); -} - -export function LanguageSelect({ - selectedCountry, - onSelect, - containerRef, - languages, -}: LanguageSelectProps) { - const [expanded, setExpanded] = useState(false); - const [isTapping, setIsTapping] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const collapsedHeight = "2.5rem"; - const expandHeight = "12rem"; - const buttonRef = useRef(null); - - const expand = () => { - setIsTapping(true); - setTimeout(() => setIsTapping(false), 600); - setExpanded(true); - buttonRef.current?.focus(); - }; - - useEffect(() => { - if (!expanded) return; - - const handleClickOutside = (event: MouseEvent) => { - if ( - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setIsClosing(true); - setTimeout(() => setIsClosing(false), 600); - setExpanded(false); - buttonRef.current?.blur(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [expanded]); - - const selectedLanguage = languages.find( - (lang) => lang.code === selectedCountry - ); - - const handleSelect = ({ - code, - e, - }: { - code: string; - e: React.MouseEvent; - }) => { - e.stopPropagation(); - e.preventDefault(); - onSelect(code); - setExpanded(false); - }; - - return ( -
- - - {!expanded ? ( -
-
- {selectedCountry === "auto" ? ( - - ) : ( - - )} - - {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name} - -
-
- ) : ( -
- - {languages.map((language) => ( - - ))} -
- )} -
- - - - -
- ); -} - -function LanguageButton({ - language, - onSelect, - selectedCountry, -}: { - language: Language; - onSelect: ({ - code, - e, - }: { - code: string; - e: React.MouseEvent; - }) => void; - selectedCountry: string; -}) { - return ( - - ); -} +import { useState, useRef, useEffect } from "react"; +import { ChevronDown, Globe } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import ReactCountryFlag from "react-country-flag"; + +export interface Language { + code: string; + name: string; + flag?: string; +} + +interface LanguageSelectProps { + selectedCountry: string; + onSelect: (country: string) => void; + containerRef: React.RefObject; + languages: Language[]; +} + +function FlagPreloader({ languages }: { languages: Language[] }) { + return ( +
+ {languages.map((language) => ( + + ))} +
+ ); +} + +export function LanguageSelect({ + selectedCountry, + onSelect, + containerRef, + languages, +}: LanguageSelectProps) { + const [expanded, setExpanded] = useState(false); + const [isTapping, setIsTapping] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const collapsedHeight = "2.5rem"; + const expandHeight = "12rem"; + const buttonRef = useRef(null); + + const expand = () => { + setIsTapping(true); + setTimeout(() => setIsTapping(false), 600); + setExpanded(true); + buttonRef.current?.focus(); + }; + + useEffect(() => { + if (!expanded) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsClosing(true); + setTimeout(() => setIsClosing(false), 600); + setExpanded(false); + buttonRef.current?.blur(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [expanded]); + + const selectedLanguage = languages.find( + (lang) => lang.code === selectedCountry + ); + + const handleSelect = ({ + code, + e, + }: { + code: string; + e: React.MouseEvent; + }) => { + e.stopPropagation(); + e.preventDefault(); + onSelect(code); + setExpanded(false); + }; + + return ( +
+ + + {!expanded ? ( +
+
+ {selectedCountry === "auto" ? ( + + ) : ( + + )} + + {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name} + +
+
+ ) : ( +
+ + {languages.map((language) => ( + + ))} +
+ )} +
+ + + + +
+ ); +} + +function LanguageButton({ + language, + onSelect, + selectedCountry, +}: { + language: Language; + onSelect: ({ + code, + e, + }: { + code: string; + e: React.MouseEvent; + }) => void; + selectedCountry: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/panel-preset-selector.tsx b/apps/web/src/components/panel-preset-selector.tsx index c5351f76c..faec916d4 100644 --- a/apps/web/src/components/panel-preset-selector.tsx +++ b/apps/web/src/components/panel-preset-selector.tsx @@ -1,91 +1,91 @@ -"use client"; - -import { Button } from "./ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; -import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; - -const PRESET_LABELS: Record = { - default: "Default", - media: "Media", - inspector: "Inspector", - "vertical-preview": "Vertical Preview", -}; - -const PRESET_DESCRIPTIONS: Record = { - default: "Media, preview, and inspector on top row, timeline on bottom", - media: "Full height media on left, preview and inspector on top row", - inspector: "Full height inspector on right, media and preview on top row", - "vertical-preview": "Full height preview on right for vertical videos", -}; - -export function PanelPresetSelector() { - const { activePreset, setActivePreset, resetPreset } = usePanelStore(); - - const handlePresetChange = (preset: PanelPreset) => { - setActivePreset(preset); - }; - - const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { - event.stopPropagation(); - resetPreset(preset); - }; - - return ( - - - - - -
- Panel Presets -
- - {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( - handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 cursor-pointer" - > -
-
- - {PRESET_LABELS[preset]} - - {activePreset === preset && ( -
- )} -
-

- {PRESET_DESCRIPTIONS[preset]} -

-
- - - ))} - - - ); -} +"use client"; + +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; +import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; + +const PRESET_LABELS: Record = { + default: "Default", + media: "Media", + inspector: "Inspector", + "vertical-preview": "Vertical Preview", +}; + +const PRESET_DESCRIPTIONS: Record = { + default: "Media, preview, and inspector on top row, timeline on bottom", + media: "Full height media on left, preview and inspector on top row", + inspector: "Full height inspector on right, media and preview on top row", + "vertical-preview": "Full height preview on right for vertical videos", +}; + +export function PanelPresetSelector() { + const { activePreset, setActivePreset, resetPreset } = usePanelStore(); + + const handlePresetChange = (preset: PanelPreset) => { + setActivePreset(preset); + }; + + const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { + event.stopPropagation(); + resetPreset(preset); + }; + + return ( + + + + + +
+ Panel Presets +
+ + {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( + handlePresetChange(preset)} + className="flex items-start justify-between gap-2 py-2 cursor-pointer" + > +
+
+ + {PRESET_LABELS[preset]} + + {activePreset === preset && ( +
+ )} +
+

+ {PRESET_DESCRIPTIONS[preset]} +

+
+ + + ))} + + + ); +} diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts index 2d7c684c3..3e5d75ba7 100644 --- a/apps/web/src/components/providers/global-prefetcher.ts +++ b/apps/web/src/components/providers/global-prefetcher.ts @@ -1,78 +1,78 @@ -"use client"; - -import { useEffect } from "react"; -import { useSoundsStore } from "@/stores/sounds-store"; - -export function useGlobalPrefetcher() { - const { - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - - useEffect(() => { - 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, - ]); -} +"use client"; + +import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +export function useGlobalPrefetcher() { + const { + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + } = useSoundsStore(); + + useEffect(() => { + 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/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx index 404831f6a..9143f0725 100644 --- a/apps/web/src/components/ui/editable-timecode.tsx +++ b/apps/web/src/components/ui/editable-timecode.tsx @@ -1,138 +1,138 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { cn } from "@/lib/utils"; -import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time"; -import { DEFAULT_FPS } from "@/stores/project-store"; - -interface EditableTimecodeProps { - time: number; - duration?: number; - format?: TimeCode; - fps?: number; - onTimeChange?: (time: number) => void; - className?: string; - disabled?: boolean; -} - -export function EditableTimecode({ - time, - duration, - format = "HH:MM:SS:FF", - fps = DEFAULT_FPS, - onTimeChange, - className, - disabled = false, -}: EditableTimecodeProps) { - const [isEditing, setIsEditing] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [hasError, setHasError] = useState(false); - const inputRef = useRef(null); - const enterPressedRef = useRef(false); - - const formattedTime = formatTimeCode(time, format, fps); - - const startEditing = () => { - if (disabled) return; - setIsEditing(true); - setInputValue(formattedTime); - setHasError(false); - enterPressedRef.current = false; - }; - - const cancelEditing = () => { - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const applyEdit = () => { - const parsedTime = parseTimeCode(inputValue, format, fps); - - if (parsedTime === null) { - setHasError(true); - return; - } - - // Clamp time to valid range - const clampedTime = Math.max( - 0, - duration ? Math.min(duration, parsedTime) : parsedTime - ); - - onTimeChange?.(clampedTime); - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - enterPressedRef.current = true; - applyEdit(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelEditing(); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - setHasError(false); - }; - - const handleBlur = () => { - // Only apply edit if Enter wasn't pressed (to avoid double processing) - if (!enterPressedRef.current && isEditing) { - applyEdit(); - } - }; - - // Focus input when entering edit mode - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - if (isEditing) { - return ( - - ); - } - - return ( - - {formattedTime} - - ); -} +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time"; +import { DEFAULT_FPS } from "@/stores/project-store"; + +interface EditableTimecodeProps { + time: number; + duration?: number; + format?: TimeCode; + fps?: number; + onTimeChange?: (time: number) => void; + className?: string; + disabled?: boolean; +} + +export function EditableTimecode({ + time, + duration, + format = "HH:MM:SS:FF", + fps = DEFAULT_FPS, + onTimeChange, + className, + disabled = false, +}: EditableTimecodeProps) { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [hasError, setHasError] = useState(false); + const inputRef = useRef(null); + const enterPressedRef = useRef(false); + + const formattedTime = formatTimeCode(time, format, fps); + + const startEditing = () => { + if (disabled) return; + setIsEditing(true); + setInputValue(formattedTime); + setHasError(false); + enterPressedRef.current = false; + }; + + const cancelEditing = () => { + setIsEditing(false); + setInputValue(""); + setHasError(false); + enterPressedRef.current = false; + }; + + const applyEdit = () => { + const parsedTime = parseTimeCode(inputValue, format, fps); + + if (parsedTime === null) { + setHasError(true); + return; + } + + // Clamp time to valid range + const clampedTime = Math.max( + 0, + duration ? Math.min(duration, parsedTime) : parsedTime + ); + + onTimeChange?.(clampedTime); + setIsEditing(false); + setInputValue(""); + setHasError(false); + enterPressedRef.current = false; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + enterPressedRef.current = true; + applyEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEditing(); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + setHasError(false); + }; + + const handleBlur = () => { + // Only apply edit if Enter wasn't pressed (to avoid double processing) + if (!enterPressedRef.current && isEditing) { + applyEdit(); + } + }; + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + if (isEditing) { + return ( + + ); + } + + return ( + + {formattedTime} + + ); +} diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index ea225c14b..9a71ba21b 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -1,86 +1,86 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowLeft, Search } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; - -interface InputWithBackProps { - isExpanded: boolean; - setIsExpanded: (isExpanded: boolean) => void; - placeholder?: string; - value?: string; - onChange?: (value: string) => void; -} - -export function InputWithBack({ - isExpanded, - setIsExpanded, - placeholder = "Search anything", - value, - onChange, -}: InputWithBackProps) { - const [containerRef, setContainerRef] = useState(null); - const [buttonOffset, setButtonOffset] = useState(-60); - - const smoothTransition = { - duration: 0.35, - ease: [0.25, 0.1, 0.25, 1] as const, - }; - - useEffect(() => { - if (containerRef) { - const rect = containerRef.getBoundingClientRect(); - setButtonOffset(-rect.left - 48); - } - }, [containerRef]); - - return ( -
- setIsExpanded(!isExpanded)} - > - - -
- - - onChange?.(e.target.value)} - /> - -
-
- ); -} +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( +
+ setIsExpanded(!isExpanded)} + > + + +
+ + + onChange?.(e.target.value)} + /> + +
+
+ ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 8f195a505..5f17a3a46 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -49,7 +49,9 @@ const Input = forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
- {variant === 'sidebar' && ( + {variant === "sidebar" && ( - + )} {props.children} @@ -69,4 +72,4 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; \ No newline at end of file +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx index 6a7677b47..50c7e7cc1 100644 --- a/apps/web/src/data/colors/syntax-ui.tsx +++ b/apps/web/src/data/colors/syntax-ui.tsx @@ -1,32 +1,32 @@ -// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) - -export const syntaxUIGradients = [ - // Cyan to Blue gradients - "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", - "linear-gradient(to right, #bfdbfe, #a5f3fc)", - "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", - - // Purple gradients - "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", - "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", - - // Blue gradients - "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", - "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", - - // Green gradients - "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", - "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", - - // Red gradient - "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", - - // Yellow/Orange gradient - "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", - - // Pink gradient - "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", - - // Neon radial gradient - "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", -]; +// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) + +export const syntaxUIGradients = [ + // Cyan to Blue gradients + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + "linear-gradient(to right, #bfdbfe, #a5f3fc)", + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + + // Purple gradients + "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", + "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", + + // Blue gradients + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + + // Green gradients + "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", + "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", + + // Red gradient + "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", + + // Yellow/Orange gradient + "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", + + // Pink gradient + "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", + + // Neon radial gradient + "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", +]; diff --git a/apps/web/src/hooks/use-highlight-scroll.ts b/apps/web/src/hooks/use-highlight-scroll.ts index 241bffb25..abc517a28 100644 --- a/apps/web/src/hooks/use-highlight-scroll.ts +++ b/apps/web/src/hooks/use-highlight-scroll.ts @@ -1,36 +1,36 @@ -import { useEffect, useState, useRef } from "react"; - -export function useHighlightScroll( - highlightId: string | null, - onClearHighlight: () => void, - highlightDuration = 1000 -) { - const [highlightedId, setHighlightedId] = useState(null); - const elementRefs = useRef>(new Map()); - - const registerElement = (id: string, element: HTMLElement | null) => { - if (element) { - elementRefs.current.set(id, element); - } else { - elementRefs.current.delete(id); - } - }; - - useEffect(() => { - if (!highlightId) return; - - setHighlightedId(highlightId); - - const target = elementRefs.current.get(highlightId); - target?.scrollIntoView({ block: "center" }); - - const timeout = setTimeout(() => { - setHighlightedId(null); - onClearHighlight(); - }, highlightDuration); - - return () => clearTimeout(timeout); - }, [highlightId, onClearHighlight, highlightDuration]); - - return { highlightedId, registerElement }; -} +import { useEffect, useState, useRef } from "react"; + +export function useHighlightScroll( + highlightId: string | null, + onClearHighlight: () => void, + highlightDuration = 1000 +) { + const [highlightedId, setHighlightedId] = useState(null); + const elementRefs = useRef>(new Map()); + + const registerElement = (id: string, element: HTMLElement | null) => { + if (element) { + elementRefs.current.set(id, element); + } else { + elementRefs.current.delete(id); + } + }; + + useEffect(() => { + if (!highlightId) return; + + setHighlightedId(highlightId); + + const target = elementRefs.current.get(highlightId); + target?.scrollIntoView({ block: "center" }); + + const timeout = setTimeout(() => { + setHighlightedId(null); + onClearHighlight(); + }, highlightDuration); + + return () => clearTimeout(timeout); + }, [highlightId, onClearHighlight, highlightDuration]); + + return { highlightedId, registerElement }; +} diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts index 0f8db198b..33f0acebd 100644 --- a/apps/web/src/hooks/use-infinite-scroll.ts +++ b/apps/web/src/hooks/use-infinite-scroll.ts @@ -1,35 +1,35 @@ -import { useRef, useCallback } from "react"; - -interface UseInfiniteScrollOptions { - onLoadMore: () => void; - hasMore: boolean; - isLoading: boolean; - threshold?: number; - enabled?: boolean; -} - -export function useInfiniteScroll({ - onLoadMore, - hasMore, - isLoading, - threshold = 200, - enabled = true, -}: UseInfiniteScrollOptions) { - const scrollAreaRef = useRef(null); - - const handleScroll = useCallback( - (event: React.UIEvent) => { - if (!enabled) return; - - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; - const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold; - - if (nearBottom && hasMore && !isLoading) { - onLoadMore(); - } - }, - [onLoadMore, hasMore, isLoading, threshold, enabled] - ); - - return { scrollAreaRef, handleScroll }; -} +import { useRef, useCallback } from "react"; + +interface UseInfiniteScrollOptions { + onLoadMore: () => void; + hasMore: boolean; + isLoading: boolean; + threshold?: number; + enabled?: boolean; +} + +export function useInfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + threshold = 200, + enabled = true, +}: UseInfiniteScrollOptions) { + const scrollAreaRef = useRef(null); + + const handleScroll = useCallback( + (event: React.UIEvent) => { + if (!enabled) return; + + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold; + + if (nearBottom && hasMore && !isLoading) { + onLoadMore(); + } + }, + [onLoadMore, hasMore, isLoading, threshold, enabled] + ); + + return { scrollAreaRef, handleScroll }; +} diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts index db9a7e8eb..261193527 100644 --- a/apps/web/src/hooks/use-sound-search.ts +++ b/apps/web/src/hooks/use-sound-search.ts @@ -1,156 +1,156 @@ -import { useEffect } from "react"; -import { useSoundsStore } from "@/stores/sounds-store"; - -/** - * Custom hook for searching sound effects with race condition protection. - * Uses global Zustand store to persist search state across tab switches. - * - Debounced search (300ms) - * - Race condition protection with cleanup - * - Proper error handling - */ - -export function useSoundSearch(query: string, commercialOnly: boolean) { - const { - searchResults, - isSearching, - searchError, - lastSearchQuery, - currentPage, - hasNextPage, - isLoadingMore, - totalCount, - setSearchResults, - setSearching, - setSearchError, - setLastSearchQuery, - setCurrentPage, - setHasNextPage, - setTotalCount, - setLoadingMore, - appendSearchResults, - appendTopSounds, - resetPagination, - } = useSoundsStore(); - - // Load more function for infinite scroll - const loadMore = async () => { - if (isLoadingMore || !hasNextPage) return; - - try { - setLoadingMore(true); - const nextPage = currentPage + 1; - - const searchParams = new URLSearchParams({ - page: nextPage.toString(), - type: "effects", - }); - - if (query.trim()) { - searchParams.set("q", query); - } - - searchParams.set("commercial_only", commercialOnly.toString()); - const response = await fetch( - `/api/sounds/search?${searchParams.toString()}` - ); - - if (response.ok) { - const data = await response.json(); - - // Append to appropriate array based on whether we have a query - if (query.trim()) { - appendSearchResults(data.results); - } else { - appendTopSounds(data.results); - } - - setCurrentPage(nextPage); - setHasNextPage(!!data.next); - setTotalCount(data.count); - } else { - setSearchError(`Load more failed: ${response.status}`); - } - } catch (err) { - setSearchError(err instanceof Error ? err.message : "Load more failed"); - } finally { - setLoadingMore(false); - } - }; - - useEffect(() => { - if (!query.trim()) { - setSearchResults([]); - setSearchError(null); - setLastSearchQuery(""); - // Don't reset pagination here - top sounds pagination is managed by prefetcher - return; - } - - // If we already searched for this query and have results, don't search again - if (query === lastSearchQuery && searchResults.length > 0) { - return; - } - - let ignore = false; - - const timeoutId = setTimeout(async () => { - try { - setSearching(true); - setSearchError(null); - resetPagination(); - - const response = await fetch( - `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1` - ); - - if (!ignore) { - if (response.ok) { - const data = await response.json(); - setSearchResults(data.results); - setLastSearchQuery(query); - setHasNextPage(!!data.next); - setTotalCount(data.count); - setCurrentPage(1); - } else { - setSearchError(`Search failed: ${response.status}`); - } - } - } catch (err) { - if (!ignore) { - setSearchError(err instanceof Error ? err.message : "Search failed"); - } - } finally { - if (!ignore) { - setSearching(false); - } - } - }, 300); - - return () => { - clearTimeout(timeoutId); - ignore = true; - }; - }, [ - query, - lastSearchQuery, - searchResults.length, - setSearchResults, - setSearching, - setSearchError, - setLastSearchQuery, - setCurrentPage, - setHasNextPage, - setTotalCount, - resetPagination, - ]); - - return { - results: searchResults, - isLoading: isSearching, - error: searchError, - loadMore, - hasNextPage, - isLoadingMore, - totalCount, - }; -} +import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +/** + * Custom hook for searching sound effects with race condition protection. + * Uses global Zustand store to persist search state across tab switches. + * - Debounced search (300ms) + * - Race condition protection with cleanup + * - Proper error handling + */ + +export function useSoundSearch(query: string, commercialOnly: boolean) { + const { + searchResults, + isSearching, + searchError, + lastSearchQuery, + currentPage, + hasNextPage, + isLoadingMore, + totalCount, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + setLoadingMore, + appendSearchResults, + appendTopSounds, + resetPagination, + } = useSoundsStore(); + + // Load more function for infinite scroll + const loadMore = async () => { + if (isLoadingMore || !hasNextPage) return; + + try { + setLoadingMore(true); + const nextPage = currentPage + 1; + + const searchParams = new URLSearchParams({ + page: nextPage.toString(), + type: "effects", + }); + + if (query.trim()) { + searchParams.set("q", query); + } + + searchParams.set("commercial_only", commercialOnly.toString()); + const response = await fetch( + `/api/sounds/search?${searchParams.toString()}` + ); + + if (response.ok) { + const data = await response.json(); + + // Append to appropriate array based on whether we have a query + if (query.trim()) { + appendSearchResults(data.results); + } else { + appendTopSounds(data.results); + } + + setCurrentPage(nextPage); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } else { + setSearchError(`Load more failed: ${response.status}`); + } + } catch (err) { + setSearchError(err instanceof Error ? err.message : "Load more failed"); + } finally { + setLoadingMore(false); + } + }; + + useEffect(() => { + if (!query.trim()) { + setSearchResults([]); + setSearchError(null); + setLastSearchQuery(""); + // Don't reset pagination here - top sounds pagination is managed by prefetcher + return; + } + + // If we already searched for this query and have results, don't search again + if (query === lastSearchQuery && searchResults.length > 0) { + return; + } + + let ignore = false; + + const timeoutId = setTimeout(async () => { + try { + setSearching(true); + setSearchError(null); + resetPagination(); + + const response = await fetch( + `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1` + ); + + if (!ignore) { + if (response.ok) { + const data = await response.json(); + setSearchResults(data.results); + setLastSearchQuery(query); + setHasNextPage(!!data.next); + setTotalCount(data.count); + setCurrentPage(1); + } else { + setSearchError(`Search failed: ${response.status}`); + } + } + } catch (err) { + if (!ignore) { + setSearchError(err instanceof Error ? err.message : "Search failed"); + } + } finally { + if (!ignore) { + setSearching(false); + } + } + }, 300); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + query, + lastSearchQuery, + searchResults.length, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + resetPagination, + ]); + + return { + results: searchResults, + isLoading: isSearching, + error: searchError, + loadMore, + hasNextPage, + isLoadingMore, + totalCount, + }; +} diff --git a/apps/web/src/lib/editor-utils.ts b/apps/web/src/lib/editor-utils.ts index f041aa5ec..c201f41b2 100644 --- a/apps/web/src/lib/editor-utils.ts +++ b/apps/web/src/lib/editor-utils.ts @@ -1,46 +1,46 @@ -import { CanvasSize } from "@/types/editor"; - -const DEFAULT_CANVAS_PRESETS = [ - { name: "16:9", width: 1920, height: 1080 }, - { name: "9:16", width: 1080, height: 1920 }, - { name: "1:1", width: 1080, height: 1080 }, - { name: "4:3", width: 1440, height: 1080 }, -]; - -/** - * Helper function to find the best matching canvas preset for an aspect ratio - * @param aspectRatio The target aspect ratio to match - * @returns The best matching canvas size - */ -export function findBestCanvasPreset(aspectRatio: number): CanvasSize { - // Calculate aspect ratio for each preset and find the closest match - let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD - let smallestDifference = Math.abs( - aspectRatio - bestMatch.width / bestMatch.height - ); - - for (const preset of DEFAULT_CANVAS_PRESETS) { - const presetAspectRatio = preset.width / preset.height; - const difference = Math.abs(aspectRatio - presetAspectRatio); - - if (difference < smallestDifference) { - smallestDifference = difference; - bestMatch = preset; - } - } - - // If the difference is still significant (> 0.1), create a custom size - // based on the media aspect ratio with a reasonable resolution - const bestAspectRatio = bestMatch.width / bestMatch.height; - if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) { - // Create custom dimensions based on the aspect ratio - if (aspectRatio > 1) { - // Landscape - use 1920 width - return { width: 1920, height: Math.round(1920 / aspectRatio) }; - } - // Portrait or square - use 1080 height - return { width: Math.round(1080 * aspectRatio), height: 1080 }; - } - - return { width: bestMatch.width, height: bestMatch.height }; -} +import { CanvasSize } from "@/types/editor"; + +const DEFAULT_CANVAS_PRESETS = [ + { name: "16:9", width: 1920, height: 1080 }, + { name: "9:16", width: 1080, height: 1920 }, + { name: "1:1", width: 1080, height: 1080 }, + { name: "4:3", width: 1440, height: 1080 }, +]; + +/** + * Helper function to find the best matching canvas preset for an aspect ratio + * @param aspectRatio The target aspect ratio to match + * @returns The best matching canvas size + */ +export function findBestCanvasPreset(aspectRatio: number): CanvasSize { + // Calculate aspect ratio for each preset and find the closest match + let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD + let smallestDifference = Math.abs( + aspectRatio - bestMatch.width / bestMatch.height + ); + + for (const preset of DEFAULT_CANVAS_PRESETS) { + const presetAspectRatio = preset.width / preset.height; + const difference = Math.abs(aspectRatio - presetAspectRatio); + + if (difference < smallestDifference) { + smallestDifference = difference; + bestMatch = preset; + } + } + + // If the difference is still significant (> 0.1), create a custom size + // based on the media aspect ratio with a reasonable resolution + const bestAspectRatio = bestMatch.width / bestMatch.height; + if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) { + // Create custom dimensions based on the aspect ratio + if (aspectRatio > 1) { + // Landscape - use 1920 width + return { width: 1920, height: Math.round(1920 / aspectRatio) }; + } + // Portrait or square - use 1080 height + return { width: Math.round(1080 * aspectRatio), height: 1080 }; + } + + return { width: bestMatch.width, height: bestMatch.height }; +} diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts index 9d1bcb972..485d46c07 100644 --- a/apps/web/src/lib/iconify-api.ts +++ b/apps/web/src/lib/iconify-api.ts @@ -1,4 +1,3 @@ - export const ICONIFY_HOSTS = [ "https://api.iconify.design", "https://api.simplesvg.com", diff --git a/apps/web/src/lib/schemas/waitlist.ts b/apps/web/src/lib/schemas/waitlist.ts index c45540d54..ec7889fc4 100644 --- a/apps/web/src/lib/schemas/waitlist.ts +++ b/apps/web/src/lib/schemas/waitlist.ts @@ -1,15 +1,15 @@ -import { z } from "zod"; - -export const exportWaitlistSchema = z.object({ - email: z.string().email().max(320), -}); - -export const exportWaitlistResponseSchema = z.object({ - success: z.boolean(), - alreadySubscribed: z.boolean().optional(), -}); - -export type ExportWaitlistInput = z.infer; -export type ExportWaitlistResponse = z.infer< - typeof exportWaitlistResponseSchema ->; +import { z } from "zod"; + +export const exportWaitlistSchema = z.object({ + email: z.string().email().max(320), +}); + +export const exportWaitlistResponseSchema = z.object({ + success: z.boolean(), + alreadySubscribed: z.boolean().optional(), +}); + +export type ExportWaitlistInput = z.infer; +export type ExportWaitlistResponse = z.infer< + typeof exportWaitlistResponseSchema +>; diff --git a/apps/web/src/lib/transcription-utils.ts b/apps/web/src/lib/transcription-utils.ts index 6388a3b6d..5199e89aa 100644 --- a/apps/web/src/lib/transcription-utils.ts +++ b/apps/web/src/lib/transcription-utils.ts @@ -1,13 +1,13 @@ -import { env } from "@/env"; - -export function isTranscriptionConfigured() { - const missingVars = []; - - if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID"); - if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID"); - if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY"); - if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME"); - if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL"); - - return { configured: missingVars.length === 0, missingVars }; -} +import { env } from "@/env"; + +export function isTranscriptionConfigured() { + const missingVars = []; + + if (!env.CLOUDFLARE_ACCOUNT_ID) missingVars.push("CLOUDFLARE_ACCOUNT_ID"); + if (!env.R2_ACCESS_KEY_ID) missingVars.push("R2_ACCESS_KEY_ID"); + if (!env.R2_SECRET_ACCESS_KEY) missingVars.push("R2_SECRET_ACCESS_KEY"); + if (!env.R2_BUCKET_NAME) missingVars.push("R2_BUCKET_NAME"); + if (!env.MODAL_TRANSCRIPTION_URL) missingVars.push("MODAL_TRANSCRIPTION_URL"); + + return { configured: missingVars.length === 0, missingVars }; +} diff --git a/apps/web/src/lib/zk-encryption.ts b/apps/web/src/lib/zk-encryption.ts index 587348074..d01d69788 100644 --- a/apps/web/src/lib/zk-encryption.ts +++ b/apps/web/src/lib/zk-encryption.ts @@ -1,71 +1,71 @@ -/** - * True zero-knowledge encryption utilities - * Keys are generated randomly in the browser and never derived from server secrets - */ - -export interface ZeroKnowledgeEncryptionResult { - encryptedData: ArrayBuffer; - key: ArrayBuffer; - iv: ArrayBuffer; -} - -/** - * Encrypt data with a randomly generated key (true zero-knowledge) - */ -export async function encryptWithRandomKey( - data: ArrayBuffer -): Promise { - // Generate a truly random 256-bit key - const key = crypto.getRandomValues(new Uint8Array(32)); - - // Generate random IV - const iv = crypto.getRandomValues(new Uint8Array(12)); - - // Import the key for encryption - const cryptoKey = await crypto.subtle.importKey( - "raw", - key, - { name: "AES-GCM" }, - false, - ["encrypt"] - ); - - // Encrypt the data - const encryptedResult = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - cryptoKey, - data - ); - - // For AES-GCM, we need to append the authentication tag - // The encrypted result contains both ciphertext and tag - return { - encryptedData: encryptedResult, - key: key.buffer, - iv: iv.buffer, - }; -} - -/** - * Convert ArrayBuffer to base64 string for transmission - */ -export function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -/** - * Convert base64 string back to ArrayBuffer - */ -export function base64ToArrayBuffer(base64: string): ArrayBuffer { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} +/** + * True zero-knowledge encryption utilities + * Keys are generated randomly in the browser and never derived from server secrets + */ + +export interface ZeroKnowledgeEncryptionResult { + encryptedData: ArrayBuffer; + key: ArrayBuffer; + iv: ArrayBuffer; +} + +/** + * Encrypt data with a randomly generated key (true zero-knowledge) + */ +export async function encryptWithRandomKey( + data: ArrayBuffer +): Promise { + // Generate a truly random 256-bit key + const key = crypto.getRandomValues(new Uint8Array(32)); + + // Generate random IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Import the key for encryption + const cryptoKey = await crypto.subtle.importKey( + "raw", + key, + { name: "AES-GCM" }, + false, + ["encrypt"] + ); + + // Encrypt the data + const encryptedResult = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + cryptoKey, + data + ); + + // For AES-GCM, we need to append the authentication tag + // The encrypted result contains both ciphertext and tag + return { + encryptedData: encryptedResult, + key: key.buffer, + iv: iv.buffer, + }; +} + +/** + * Convert ArrayBuffer to base64 string for transmission + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Convert base64 string back to ArrayBuffer + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index c2b9cf4af..939c73cce 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -37,7 +37,9 @@ const startTimer = (store: () => PlaybackStore) => { // When content completes, pause just before the end so we can see the last frame const projectFps = useProjectStore.getState().activeProject?.fps; if (!projectFps) - console.error("Project FPS is not set, assuming " + DEFAULT_FPS + "fps"); + console.error( + "Project FPS is not set, assuming " + DEFAULT_FPS + "fps" + ); const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS const stopTime = Math.max(0, effectiveDuration - frameOffset); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 215253fbf..f44931fe5 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -1,282 +1,282 @@ -import { create } from "zustand"; -import type { SoundEffect, SavedSound } from "@/types/sounds"; -import { storageService } from "@/lib/storage/storage-service"; -import { toast } from "sonner"; -import { useMediaStore } from "./media-store"; -import { useTimelineStore } from "./timeline-store"; -import { useProjectStore } from "./project-store"; -import { usePlaybackStore } from "./playback-store"; - -interface SoundsStore { - topSoundEffects: SoundEffect[]; - isLoading: boolean; - error: string | null; - hasLoaded: boolean; - - // Filter state - showCommercialOnly: boolean; - toggleCommercialFilter: () => void; - - // Search state - searchQuery: string; - searchResults: SoundEffect[]; - isSearching: boolean; - searchError: string | null; - lastSearchQuery: string; - scrollPosition: number; - - // Pagination state - currentPage: number; - hasNextPage: boolean; - totalCount: number; - isLoadingMore: boolean; - - // Saved sounds state - savedSounds: SavedSound[]; - isSavedSoundsLoaded: boolean; - isLoadingSavedSounds: boolean; - savedSoundsError: string | null; - - // Timeline integration - addSoundToTimeline: (sound: SoundEffect) => Promise; - - setTopSoundEffects: (sounds: SoundEffect[]) => void; - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setHasLoaded: (loaded: boolean) => void; - - // Search actions - setSearchQuery: (query: string) => void; - setSearchResults: (results: SoundEffect[]) => void; - setSearching: (searching: boolean) => void; - setSearchError: (error: string | null) => void; - setLastSearchQuery: (query: string) => void; - setScrollPosition: (position: number) => void; - - // Pagination actions - setCurrentPage: (page: number) => void; - setHasNextPage: (hasNext: boolean) => void; - setTotalCount: (count: number) => void; - setLoadingMore: (loading: boolean) => void; - appendSearchResults: (results: SoundEffect[]) => void; - appendTopSounds: (results: SoundEffect[]) => void; - resetPagination: () => void; - - // Saved sounds actions - loadSavedSounds: () => Promise; - saveSoundEffect: (soundEffect: SoundEffect) => Promise; - removeSavedSound: (soundId: number) => Promise; - isSoundSaved: (soundId: number) => boolean; - toggleSavedSound: (soundEffect: SoundEffect) => Promise; - clearSavedSounds: () => Promise; -} - -export const useSoundsStore = create((set, get) => ({ - topSoundEffects: [], - isLoading: false, - error: null, - hasLoaded: false, - showCommercialOnly: true, - - toggleCommercialFilter: () => { - set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); - }, - - // Search state - searchQuery: "", - searchResults: [], - isSearching: false, - searchError: null, - lastSearchQuery: "", - scrollPosition: 0, - - // Pagination state - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - - // Saved sounds state - savedSounds: [], - isSavedSoundsLoaded: false, - isLoadingSavedSounds: false, - savedSoundsError: null, - - setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }), - setLoading: (loading) => set({ isLoading: loading }), - setError: (error) => set({ error }), - setHasLoaded: (loaded) => set({ hasLoaded: loaded }), - - // Search actions - setSearchQuery: (query) => set({ searchQuery: query }), - setSearchResults: (results) => - set({ searchResults: results, currentPage: 1 }), - setSearching: (searching) => set({ isSearching: searching }), - setSearchError: (error) => set({ searchError: error }), - setLastSearchQuery: (query) => set({ lastSearchQuery: query }), - setScrollPosition: (position) => set({ scrollPosition: position }), - - // Pagination actions - setCurrentPage: (page) => set({ currentPage: page }), - setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }), - setTotalCount: (count) => set({ totalCount: count }), - setLoadingMore: (loading) => set({ isLoadingMore: loading }), - appendSearchResults: (results) => - set((state) => ({ - searchResults: [...state.searchResults, ...results], - })), - appendTopSounds: (results) => - set((state) => ({ - topSoundEffects: [...state.topSoundEffects, ...results], - })), - resetPagination: () => - set({ - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - }), - - // Saved sounds actions - loadSavedSounds: async () => { - if (get().isSavedSoundsLoaded) return; - - try { - set({ isLoadingSavedSounds: true, savedSoundsError: null }); - const savedSoundsData = await storageService.loadSavedSounds(); - set({ - savedSounds: savedSoundsData.sounds, - isSavedSoundsLoaded: true, - isLoadingSavedSounds: false, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to load saved sounds"; - set({ - savedSoundsError: errorMessage, - isLoadingSavedSounds: false, - }); - console.error("Failed to load saved sounds:", error); - } - }, - - saveSoundEffect: async (soundEffect: SoundEffect) => { - try { - await storageService.saveSoundEffect(soundEffect); - - // Refresh saved sounds - const savedSoundsData = await storageService.loadSavedSounds(); - set({ savedSounds: savedSoundsData.sounds }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to save sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to save sound"); - console.error("Failed to save sound:", error); - } - }, - - removeSavedSound: async (soundId: number) => { - try { - await storageService.removeSavedSound(soundId); - - // Update local state immediately - set((state) => ({ - savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), - })); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to remove sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to remove sound"); - console.error("Failed to remove sound:", error); - } - }, - - isSoundSaved: (soundId: number) => { - const { savedSounds } = get(); - return savedSounds.some((sound) => sound.id === soundId); - }, - - toggleSavedSound: async (soundEffect: SoundEffect) => { - const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); - - if (isSoundSaved(soundEffect.id)) { - await removeSavedSound(soundEffect.id); - } else { - await saveSoundEffect(soundEffect); - } - }, - - clearSavedSounds: async () => { - try { - await storageService.clearSavedSounds(); - set({ - savedSounds: [], - savedSoundsError: null, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to clear saved sounds"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to clear saved sounds"); - console.error("Failed to clear saved sounds:", error); - } - }, - - addSoundToTimeline: async (sound) => { - const activeProject = useProjectStore.getState().activeProject; - if (!activeProject) { - toast.error("No active project"); - return false; - } - - const audioUrl = sound.previewUrl; - if (!audioUrl) { - toast.error("Sound file not available"); - return false; - } - - try { - const response = await fetch(audioUrl); - if (!response.ok) - throw new Error(`Failed to download audio: ${response.statusText}`); - - const blob = await response.blob(); - const file = new File([blob], `${sound.name}.mp3`, { - type: "audio/mpeg", - }); - - await useMediaStore.getState().addMediaItem(activeProject.id, { - name: sound.name, - type: "audio", - file, - duration: sound.duration, - url: URL.createObjectURL(file), - }); - - const mediaItem = useMediaStore - .getState() - .mediaItems.find((item) => item.file === file); - if (!mediaItem) throw new Error("Failed to create media item"); - - const success = useTimelineStore - .getState() - .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); - - if (success) { - return true; - } - throw new Error("Failed to add to timeline - check for overlaps"); - } catch (error) { - console.error("Failed to add sound to timeline:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to add sound to timeline", - { id: `sound-${sound.id}` } - ); - return false; - } - }, -})); +import { create } from "zustand"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; +import { useMediaStore } from "./media-store"; +import { useTimelineStore } from "./timeline-store"; +import { useProjectStore } from "./project-store"; +import { usePlaybackStore } from "./playback-store"; + +interface SoundsStore { + topSoundEffects: SoundEffect[]; + isLoading: boolean; + error: string | null; + hasLoaded: boolean; + + // Filter state + showCommercialOnly: boolean; + toggleCommercialFilter: () => void; + + // Search state + searchQuery: string; + searchResults: SoundEffect[]; + isSearching: boolean; + searchError: string | null; + lastSearchQuery: string; + scrollPosition: number; + + // Pagination state + currentPage: number; + hasNextPage: boolean; + totalCount: number; + isLoadingMore: boolean; + + // Saved sounds state + savedSounds: SavedSound[]; + isSavedSoundsLoaded: boolean; + isLoadingSavedSounds: boolean; + savedSoundsError: string | null; + + // Timeline integration + addSoundToTimeline: (sound: SoundEffect) => Promise; + + setTopSoundEffects: (sounds: SoundEffect[]) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setHasLoaded: (loaded: boolean) => void; + + // Search actions + setSearchQuery: (query: string) => void; + setSearchResults: (results: SoundEffect[]) => void; + setSearching: (searching: boolean) => void; + setSearchError: (error: string | null) => void; + setLastSearchQuery: (query: string) => void; + setScrollPosition: (position: number) => void; + + // Pagination actions + setCurrentPage: (page: number) => void; + setHasNextPage: (hasNext: boolean) => void; + setTotalCount: (count: number) => void; + setLoadingMore: (loading: boolean) => void; + appendSearchResults: (results: SoundEffect[]) => void; + appendTopSounds: (results: SoundEffect[]) => void; + resetPagination: () => void; + + // Saved sounds actions + loadSavedSounds: () => Promise; + saveSoundEffect: (soundEffect: SoundEffect) => Promise; + removeSavedSound: (soundId: number) => Promise; + isSoundSaved: (soundId: number) => boolean; + toggleSavedSound: (soundEffect: SoundEffect) => Promise; + clearSavedSounds: () => Promise; +} + +export const useSoundsStore = create((set, get) => ({ + topSoundEffects: [], + isLoading: false, + error: null, + hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, + + // Search state + searchQuery: "", + searchResults: [], + isSearching: false, + searchError: null, + lastSearchQuery: "", + scrollPosition: 0, + + // Pagination state + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + + // Saved sounds state + savedSounds: [], + isSavedSoundsLoaded: false, + isLoadingSavedSounds: false, + savedSoundsError: null, + + setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }), + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + setHasLoaded: (loaded) => set({ hasLoaded: loaded }), + + // Search actions + setSearchQuery: (query) => set({ searchQuery: query }), + setSearchResults: (results) => + set({ searchResults: results, currentPage: 1 }), + setSearching: (searching) => set({ isSearching: searching }), + setSearchError: (error) => set({ searchError: error }), + setLastSearchQuery: (query) => set({ lastSearchQuery: query }), + setScrollPosition: (position) => set({ scrollPosition: position }), + + // Pagination actions + setCurrentPage: (page) => set({ currentPage: page }), + setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }), + setTotalCount: (count) => set({ totalCount: count }), + setLoadingMore: (loading) => set({ isLoadingMore: loading }), + appendSearchResults: (results) => + set((state) => ({ + searchResults: [...state.searchResults, ...results], + })), + appendTopSounds: (results) => + set((state) => ({ + topSoundEffects: [...state.topSoundEffects, ...results], + })), + resetPagination: () => + set({ + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + }), + + // Saved sounds actions + loadSavedSounds: async () => { + if (get().isSavedSoundsLoaded) return; + + try { + set({ isLoadingSavedSounds: true, savedSoundsError: null }); + const savedSoundsData = await storageService.loadSavedSounds(); + set({ + savedSounds: savedSoundsData.sounds, + isSavedSoundsLoaded: true, + isLoadingSavedSounds: false, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to load saved sounds"; + set({ + savedSoundsError: errorMessage, + isLoadingSavedSounds: false, + }); + console.error("Failed to load saved sounds:", error); + } + }, + + saveSoundEffect: async (soundEffect: SoundEffect) => { + try { + await storageService.saveSoundEffect(soundEffect); + + // Refresh saved sounds + const savedSoundsData = await storageService.loadSavedSounds(); + set({ savedSounds: savedSoundsData.sounds }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to save sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to save sound"); + console.error("Failed to save sound:", error); + } + }, + + removeSavedSound: async (soundId: number) => { + try { + await storageService.removeSavedSound(soundId); + + // Update local state immediately + set((state) => ({ + savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to remove sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to remove sound"); + console.error("Failed to remove sound:", error); + } + }, + + isSoundSaved: (soundId: number) => { + const { savedSounds } = get(); + return savedSounds.some((sound) => sound.id === soundId); + }, + + toggleSavedSound: async (soundEffect: SoundEffect) => { + const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); + + if (isSoundSaved(soundEffect.id)) { + await removeSavedSound(soundEffect.id); + } else { + await saveSoundEffect(soundEffect); + } + }, + + clearSavedSounds: async () => { + try { + await storageService.clearSavedSounds(); + set({ + savedSounds: [], + savedSoundsError: null, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to clear saved sounds"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to clear saved sounds"); + console.error("Failed to clear saved sounds:", error); + } + }, + + addSoundToTimeline: async (sound) => { + const activeProject = useProjectStore.getState().activeProject; + if (!activeProject) { + toast.error("No active project"); + return false; + } + + const audioUrl = sound.previewUrl; + if (!audioUrl) { + toast.error("Sound file not available"); + return false; + } + + try { + const response = await fetch(audioUrl); + if (!response.ok) + throw new Error(`Failed to download audio: ${response.statusText}`); + + const blob = await response.blob(); + const file = new File([blob], `${sound.name}.mp3`, { + type: "audio/mpeg", + }); + + await useMediaStore.getState().addMediaItem(activeProject.id, { + name: sound.name, + type: "audio", + file, + duration: sound.duration, + url: URL.createObjectURL(file), + }); + + const mediaItem = useMediaStore + .getState() + .mediaItems.find((item) => item.file === file); + if (!mediaItem) throw new Error("Failed to create media item"); + + const success = useTimelineStore + .getState() + .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); + + if (success) { + return true; + } + throw new Error("Failed to add to timeline - check for overlaps"); + } catch (error) { + console.error("Failed to add sound to timeline:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to add sound to timeline", + { id: `sound-${sound.id}` } + ); + return false; + } + }, +})); diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/types/sounds.ts index faa485eba..8b47edfa5 100644 --- a/apps/web/src/types/sounds.ts +++ b/apps/web/src/types/sounds.ts @@ -1,39 +1,39 @@ -export interface SoundEffect { - id: number; - name: string; - description: string; - url: string; - previewUrl?: string; - downloadUrl?: string; - duration: number; - filesize: number; - type: string; - channels: number; - bitrate: number; - bitdepth: number; - samplerate: number; - username: string; - tags: string[]; - license: string; - created: string; - downloads: number; - rating: number; - ratingCount: number; -} - -export interface SavedSound { - id: number; // freesound id - name: string; - username: string; - previewUrl?: string; - downloadUrl?: string; - duration: number; - tags: string[]; - license: string; - savedAt: string; // iso date string -} - -export interface SavedSoundsData { - sounds: SavedSound[]; - lastModified: string; -} +export interface SoundEffect { + id: number; + name: string; + description: string; + url: string; + previewUrl?: string; + downloadUrl?: string; + duration: number; + filesize: number; + type: string; + channels: number; + bitrate: number; + bitdepth: number; + samplerate: number; + username: string; + tags: string[]; + license: string; + created: string; + downloads: number; + rating: number; + ratingCount: number; +} + +export interface SavedSound { + id: number; // freesound id + name: string; + username: string; + previewUrl?: string; + downloadUrl?: string; + duration: number; + tags: string[]; + license: string; + savedAt: string; // iso date string +} + +export interface SavedSoundsData { + sounds: SavedSound[]; + lastModified: string; +} diff --git a/bun.lock b/bun.lock index ea7c8c9a1..018ca7872 100644 --- a/bun.lock +++ b/bun.lock @@ -1320,10 +1320,10 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], - "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="], "opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="],