summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorenkei64 <imenkei64@gmail.com>2025-08-04 18:11:24 +1000
committerGitHub <noreply@github.com>2025-08-04 18:11:24 +1000
commit21b248718b0d1f6e7f32a8bac0cc1277241b7bd5 (patch)
tree70d9c53fc561a9100dd4c7d1cb6d49d45e35b989 /apps/web/src
parentbb36b2e439bffcaa7059e3c0578a0fc61c2737ac (diff)
parentd022a78ae2330665e7d763fdc6e831a71f3b9ea1 (diff)
downloadopencut-21b248718b0d1f6e7f32a8bac0cc1277241b7bd5.tar.gz
Merge branch 'staging' into feature/panel-presets
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts265
-rw-r--r--apps/web/src/app/api/waitlist/route.ts105
-rw-r--r--apps/web/src/app/api/waitlist/token/route.ts61
-rw-r--r--apps/web/src/app/editor/[project_id]/layout.tsx13
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx75
-rw-r--r--apps/web/src/app/globals.css4
-rw-r--r--apps/web/src/app/layout.tsx4
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx4
-rw-r--r--apps/web/src/components/editor/media-panel/store.ts6
-rw-r--r--apps/web/src/components/editor/media-panel/views/audio.tsx19
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx500
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx2
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx109
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx9
-rw-r--r--apps/web/src/components/icons.tsx22
-rw-r--r--apps/web/src/components/providers/global-prefetcher.ts78
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx10
-rw-r--r--apps/web/src/components/ui/input.tsx64
-rw-r--r--apps/web/src/components/ui/scroll-area.tsx55
-rw-r--r--apps/web/src/env.ts4
-rw-r--r--apps/web/src/hooks/use-sound-search.ts156
-rw-r--r--apps/web/src/lib/rate-limit.ts6
-rw-r--r--apps/web/src/lib/storage/storage-service.ts92
-rw-r--r--apps/web/src/lib/storage/types.ts1
-rw-r--r--apps/web/src/lib/waitlist.ts13
-rw-r--r--apps/web/src/stores/sounds-store.ts282
-rw-r--r--apps/web/src/stores/timeline-store.ts22
-rw-r--r--apps/web/src/types/sounds.ts39
28 files changed, 1564 insertions, 456 deletions
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
new file mode 100644
index 00000000..c89bc76c
--- /dev/null
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -0,0 +1,265 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+
+const searchParamsSchema = z.object({
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+ commercial_only: z.coerce.boolean().default(true),
+});
+
+const freesoundResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
+});
+
+const freesoundResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
+});
+
+const transformedResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
+});
+
+const apiResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ const { searchParams } = new URL(request.url);
+
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
+
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
+
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 }
+ );
+ }
+
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
+
+ // Use score sorting for search queries, downloads for top sounds
+ const sortParam = query
+ ? sort === "score"
+ ? "score"
+ : `${sort}_desc`
+ : `${sort}_desc`;
+
+ const params = new URLSearchParams({
+ query: query || "",
+ token: env.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
+
+ // Always apply sound effect filters (since we're primarily a sound effects search)
+ if (type === "effects" || !type) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
+
+ // Filter by license if commercial_only is true
+ if (commercial_only) {
+ params.append(
+ "filter",
+ 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")'
+ );
+ }
+
+ params.append(
+ "filter",
+ "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion"
+ );
+ }
+
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status }
+ );
+ }
+
+ const rawData = await response.json();
+
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 }
+ );
+ }
+
+ const data = freesoundValidation.data;
+
+ const transformedResults = data.results.map((result) => ({
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ }));
+
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts
deleted file mode 100644
index 9f00f221..00000000
--- a/apps/web/src/app/api/waitlist/route.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { db, eq, waitlist } from "@opencut/db";
-import { checkBotId } from "botid/server";
-import { nanoid } from "nanoid";
-import { waitlistRateLimit } from "@/lib/rate-limit";
-import { z } from "zod";
-import { env } from "@/env";
-import { cookies } from "next/headers";
-import crypto from "crypto";
-
-const waitlistSchema = z.object({
- email: z.string().email("Invalid email format").min(1, "Email is required"),
-});
-
-const CSRF_TOKEN_NAME = "waitlist-csrf";
-const TOKEN_EXPIRY = 60 * 60 * 1000;
-
-async function validateCSRFToken(request: NextRequest): Promise<boolean> {
- const clientToken = request.headers.get("x-csrf-token");
- if (!clientToken) return false;
-
- const cookieStore = await cookies();
- const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
- if (!cookieValue) return false;
-
- const [token, timestamp, signature] = cookieValue.split(":");
- if (!token || !timestamp || !signature) return false;
-
- if (clientToken !== token) return false;
-
- const now = Date.now();
- const tokenTime = parseInt(timestamp);
- if (now - tokenTime > TOKEN_EXPIRY) return false;
-
- const expectedSignature = crypto
- .createHmac("sha256", env.BETTER_AUTH_SECRET)
- .update(`${token}:${timestamp}`)
- .digest("hex");
-
- return signature === expectedSignature;
-}
-
-export async function POST(request: NextRequest) {
- const verification = await checkBotId();
-
- if (verification.isBot) {
- return NextResponse.json({ error: "Access denied" }, { status: 403 });
- }
-
- const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
- const { success } = await waitlistRateLimit.limit(identifier);
-
- if (!success) {
- return NextResponse.json(
- { error: "Too many requests. Please try again later." },
- { status: 429 }
- );
- }
- const isValidToken = await validateCSRFToken(request);
- if (!isValidToken) {
- return NextResponse.json(
- { error: "Invalid security token" },
- { status: 403 }
- );
- }
-
- try {
- const body = await request.json();
- const { email } = waitlistSchema.parse(body);
-
- const existingEmail = await db
- .select()
- .from(waitlist)
- .where(eq(waitlist.email, email.toLowerCase()))
- .limit(1);
-
- if (existingEmail.length > 0) {
- return NextResponse.json(
- { error: "Email already registered" },
- { status: 409 }
- );
- }
-
- await db.insert(waitlist).values({
- id: nanoid(),
- email: email.toLowerCase(),
- });
-
- return NextResponse.json(
- { message: "Successfully joined waitlist!" },
- { status: 201 }
- );
- } catch (error) {
- if (error instanceof z.ZodError) {
- const firstError = error.errors[0];
- return NextResponse.json({ error: firstError.message }, { status: 400 });
- }
-
- console.error("Waitlist signup error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts
deleted file mode 100644
index fc2ff32c..00000000
--- a/apps/web/src/app/api/waitlist/token/route.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { cookies } from "next/headers";
-import crypto from "crypto";
-import { env } from "@/env";
-
-const CSRF_TOKEN_NAME = "waitlist-csrf";
-const TOKEN_EXPIRY = 60 * 60 * 1000;
-const allowedHosts =
- env.NODE_ENV === "development"
- ? ["localhost:3000", "127.0.0.1:3000"]
- : ["opencut.app", "www.opencut.app"];
-
-export async function GET(request: NextRequest) {
- const referer = request.headers.get("referer");
- const host = request.headers.get("host");
-
- if (referer) {
- const refererUrl = new URL(referer);
-
- if (
- !allowedHosts.some(
- (allowed) =>
- refererUrl.host === allowed || refererUrl.host.endsWith(allowed)
- )
- ) {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- } else if (host) {
- if (
- !allowedHosts.some(
- (allowed) => host === allowed || host.endsWith(allowed)
- )
- ) {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
- } else {
- return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- }
-
- if (!env.BETTER_AUTH_SECRET) {
- throw new Error("BETTER_AUTH_SECRET must be configured");
- }
-
- const token = crypto.randomBytes(32).toString("hex");
- const timestamp = Date.now();
- const signature = crypto
- .createHmac("sha256", env.BETTER_AUTH_SECRET)
- .update(`${token}:${timestamp}`)
- .digest("hex");
-
- const cookieStore = await cookies();
- cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, {
- httpOnly: true,
- secure: env.NODE_ENV === "production",
- sameSite: "strict",
- maxAge: TOKEN_EXPIRY / 1000,
- path: "/",
- });
-
- return NextResponse.json({ token });
-}
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx
new file mode 100644
index 00000000..151f7f94
--- /dev/null
+++ b/apps/web/src/app/editor/[project_id]/layout.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher";
+
+export default function EditorLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ useGlobalPrefetcher();
+
+ return <div>{children}</div>;
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 9e3d1b9e..f1252a44 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -237,81 +237,6 @@ export default function Editor() {
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
- defaultSize={100 - propertiesPanel}
- minSize={60}
- className="min-w-0 min-h-0"
- >
- <ResizablePanelGroup
- direction="vertical"
- className="h-full w-full gap-[0.18rem]"
- >
- <ResizablePanel
- defaultSize={mainContent}
- minSize={30}
- maxSize={85}
- onResize={setMainContent}
- className="min-h-0"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="h-full w-full gap-[0.19rem]"
- >
- <ResizablePanel
- defaultSize={toolsPanel}
- minSize={15}
- maxSize={40}
- onResize={setToolsPanel}
- className="min-w-0 rounded-sm"
- >
- <MediaPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={previewPanel}
- minSize={30}
- onResize={setPreviewPanel}
- className="min-w-0 min-h-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={timeline}
- minSize={15}
- maxSize={70}
- onResize={setTimeline}
- className="min-h-0"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={propertiesPanel}
- minSize={15}
- maxSize={40}
- onResize={setPropertiesPanel}
- className="min-w-0 rounded-sm"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- ) : activePreset === "vertical-preview" ? (
- <ResizablePanelGroup
- key={`vertical-preview-${activePreset}-${resetCounter}`}
- direction="horizontal"
- className="h-full w-full gap-[0.18rem] px-3 pb-3"
- >
- <ResizablePanel
defaultSize={100 - previewPanel}
minSize={60}
className="min-w-0 min-h-0"
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index b25f2c1e..ca78ee26 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -23,7 +23,7 @@
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%);
--accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0 100% 40%);
+ --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--input: hsl(0 0% 85.1%);
@@ -229,7 +229,7 @@
@utility scrollbar-thin {
&::-webkit-scrollbar {
- width: 8px;
+ width: 6px;
height: 8px;
}
&::-webkit-scrollbar-track {
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index a64c4440..c5a9bf45 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -14,8 +14,8 @@ export const metadata = baseMetaData;
const protectedRoutes = [
{
- path: "/api/waitlist",
- method: "POST",
+ path: "/none",
+ method: "GET",
},
];
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
index 4d80b642..70eb69dd 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -4,7 +4,7 @@ import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
-import { AudioView } from "./views/audio";
+import { SoundsView } from "./views/sounds";
import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
@@ -13,7 +13,7 @@ export function MediaPanel() {
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
- audio: <AudioView />,
+ sounds: <SoundsView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts
index 577417a9..e1212030 100644
--- a/apps/web/src/components/editor/media-panel/store.ts
+++ b/apps/web/src/components/editor/media-panel/store.ts
@@ -15,7 +15,7 @@ import { create } from "zustand";
export type Tab =
| "media"
- | "audio"
+ | "sounds"
| "text"
| "stickers"
| "effects"
@@ -30,9 +30,9 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
icon: VideoIcon,
label: "Media",
},
- audio: {
+ sounds: {
icon: MusicIcon,
- label: "Audio",
+ label: "Sounds",
},
text: {
icon: TypeIcon,
diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx
deleted file mode 100644
index 0d3c3f1e..00000000
--- a/apps/web/src/components/editor/media-panel/views/audio.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-"use client";
-
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function AudioView() {
- const [search, setSearch] = useState("");
- return (
- <div className="h-full flex flex-col gap-2 p-4">
- <Input
- placeholder="Search songs and artists"
- className="bg-panel-accent"
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- />
- <div className="flex flex-col gap-2" />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
new file mode 100644
index 00000000..ed0db8ab
--- /dev/null
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -0,0 +1,500 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { useState, useMemo, useRef, useEffect } from "react";
+import { Separator } from "@/components/ui/separator";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ PlayIcon,
+ PauseIcon,
+ HeartIcon,
+ PlusIcon,
+ ListFilter,
+} from "lucide-react";
+import { useSoundsStore } from "@/stores/sounds-store";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import type { SoundEffect, SavedSound } from "@/types/sounds";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+export function SoundsView() {
+ return (
+ <div className="h-full flex flex-col">
+ <Tabs defaultValue="sound-effects" className="flex flex-col h-full">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="songs">Songs</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ isSoundSaved,
+ toggleSavedSound,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch(searchQuery, showCommercialOnly);
+
+ // Audio playback state
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null
+ );
+
+ // Scroll position persistence
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ // Load saved sounds and restore scroll position when component mounts
+ useEffect(() => {
+ loadSavedSounds();
+
+ if (scrollAreaRef.current && scrollPosition > 0) {
+ const timeoutId = setTimeout(() => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ }, 100); // Small delay to ensure content is rendered
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, []); // Only run on mount
+
+ // Track scroll position changes and handle infinite scroll
+ const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ setScrollPosition(scrollTop);
+
+ // Trigger loadMore when scrolled to within 200px of bottom
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
+ if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) {
+ loadMore();
+ }
+ };
+
+ // Use your existing design, just swap the data source
+ const displayedSounds = useMemo(() => {
+ const sounds = searchQuery ? searchResults : topSoundEffects;
+ return sounds;
+ }, [searchQuery, searchResults, topSoundEffects]);
+
+ const playSound = (sound: SoundEffect) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ // Stop previous sound
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", (e) => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="bg-panel-accent w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ showClearIcon
+ onClear={() => setSearchQuery("")}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <ListFilter className="w-4 h-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={toggleCommercialFilter}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="flex-1 h-full"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScroll}
+ >
+ <div className="flex flex-col gap-4">
+ {isLoading && !searchQuery && (
+ <div className="text-muted-foreground text-sm">
+ Loading sounds...
+ </div>
+ )}
+ {isSearching && searchQuery && (
+ <div className="text-muted-foreground text-sm">Searching...</div>
+ )}
+ {displayedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={sound}
+ isPlaying={playingId === sound.id}
+ onPlay={() => playSound(sound)}
+ isSaved={isSoundSaved(sound.id)}
+ onToggleSaved={() => toggleSavedSound(sound)}
+ />
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+ <div className="text-muted-foreground text-sm">
+ {searchQuery ? "No sounds found" : "No sounds available"}
+ </div>
+ )}
+ {isLoadingMore && (
+ <div className="text-muted-foreground text-sm text-center py-4">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ isSoundSaved,
+ toggleSavedSound,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ // Audio playback state
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null
+ );
+
+ // Clear confirmation dialog state
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ // Load saved sounds when tab becomes active
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = (sound: SavedSound) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ // Stop previous sound
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", (e) => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ // Convert SavedSound to SoundEffect for compatibility with AudioItem
+ const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
+ <HeartIcon
+ className="w-10 h-10 text-muted-foreground"
+ strokeWidth={1.5}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-sm text-muted-foreground text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex items-center justify-between">
+ <p className="text-sm text-muted-foreground">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto text-muted-foreground hover:text-destructive !opacity-100"
+ >
+ Clear all
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Clear all saved sounds?</DialogTitle>
+ <DialogDescription>
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="text" onClick={() => setShowClearDialog(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={async () => {
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="flex-1 h-full">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect(sound)}
+ isPlaying={playingId === sound.id}
+ onPlay={() => playSound(sound)}
+ isSaved={isSoundSaved(sound.id)}
+ onToggleSaved={() =>
+ toggleSavedSound(convertToSoundEffect(sound))
+ }
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: () => void;
+ isSaved: boolean;
+ onToggleSaved: () => void;
+}
+
+function AudioItem({
+ sound,
+ isPlaying,
+ onPlay,
+ isSaved,
+ onToggleSaved,
+}: AudioItemProps) {
+ const { addSoundToTimeline } = useSoundsStore();
+
+ const handleClick = () => {
+ onPlay();
+ };
+
+ const handleSaveClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggleSaved();
+ };
+
+ const handleAddToTimeline = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ await addSoundToTimeline(sound);
+ };
+
+ return (
+ <div
+ className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer"
+ onClick={handleClick}
+ >
+ <div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
+ <div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
+ {isPlaying ? (
+ <PauseIcon className="w-5 h-5" />
+ ) : (
+ <PlayIcon className="w-5 h-5" />
+ )}
+ </div>
+
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <p className="font-medium truncate text-sm">{sound.name}</p>
+ <span className="text-xs text-muted-foreground truncate block">
+ {sound.username}
+ </span>
+ </div>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground !opacity-100 w-auto"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <PlusIcon className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground !opacity-100 w-auto ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 96166d43..14b086fe 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -383,7 +383,7 @@ export function PreviewPanel() {
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
- whiteSpace: "nowrap",
+ whiteSpace: "pre-wrap",
// Fallback for system fonts that don't have classes
...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
index f1b3f6a3..6c79327e 100644
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -1,95 +1,22 @@
"use client";
-import { FPS_PRESETS } from "@/constants/timeline-constants";
-import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { useMediaStore } from "@/stores/media-store";
-import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
-import { Label } from "../../ui/label";
import { ScrollArea } from "../../ui/scroll-area";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../../ui/select";
import { AudioProperties } from "./audio-properties";
import { MediaProperties } from "./media-properties";
-import {
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
import { TextProperties } from "./text-properties";
+import { SquareSlashIcon } from "lucide-react";
export function PropertiesPanel() {
- const { activeProject, updateProjectFps } = useProjectStore();
- const { getDisplayName, canvasSize } = useAspectRatio();
const { selectedElements, tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const handleFpsChange = (value: string) => {
- const fps = parseFloat(value);
- if (!isNaN(fps) && fps > 0) {
- updateProjectFps(fps);
- }
- };
-
- const emptyView = (
- <div className="space-y-4 p-5">
- {/* Media Properties */}
- <div className="flex flex-col gap-3">
- <PropertyItem direction="column">
- <PropertyItemLabel className="text-xs text-muted-foreground">
- Name:
- </PropertyItemLabel>
- <PropertyItemValue className="text-xs truncate">
- {activeProject?.name || ""}
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel className="text-xs text-muted-foreground">
- Aspect ratio:
- </PropertyItemLabel>
- <PropertyItemValue className="text-xs truncate">
- {getDisplayName()}
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel className="text-xs text-muted-foreground">
- Resolution:
- </PropertyItemLabel>
- <PropertyItemValue className="text-xs truncate">
- {`${canvasSize.width} × ${canvasSize.height}`}
- </PropertyItemValue>
- </PropertyItem>
- <div className="flex flex-col gap-1">
- <Label className="text-xs text-muted-foreground">Frame rate:</Label>
- <Select
- value={(activeProject?.fps || "N/A").toString()}
- onValueChange={handleFpsChange}
- >
- <SelectTrigger className="w-32 h-6 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map(({ value, label }) => (
- <SelectItem key={value} value={value} className="text-xs">
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- </div>
- );
-
return (
- <ScrollArea className="h-full bg-panel rounded-sm">
- {selectedElements.length > 0
- ? selectedElements.map(({ trackId, elementId }) => {
+ <>
+ {selectedElements.length > 0 ? (
+ <ScrollArea className="h-full bg-panel rounded-sm">
+ {selectedElements.map(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
@@ -116,8 +43,28 @@ export function PropertiesPanel() {
);
}
return null;
- })
- : emptyView}
- </ScrollArea>
+ })}
+ </ScrollArea>
+ ) : (
+ <EmptyView />
+ )}
+ </>
+ );
+}
+
+function EmptyView() {
+ return (
+ <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
+ <SquareSlashIcon
+ className="w-10 h-10 text-muted-foreground"
+ strokeWidth={1.5}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">It’s empty here</p>
+ <p className="text-sm text-muted-foreground text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
);
}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 8f41fbf3..7a2cc7f2 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ScrollArea } from "../../ui/scroll-area";
+import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "../../ui/button";
import {
Scissors,
@@ -746,12 +746,7 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea
- className="w-full h-full"
- ref={tracksScrollRef}
- type="scroll"
- showHorizontalScrollbar
- >
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
index 065a123d..43f953e8 100644
--- a/apps/web/src/components/icons.tsx
+++ b/apps/web/src/components/icons.tsx
@@ -163,25 +163,3 @@ export function DataBuddyIcon({
</svg>
);
}
-
-export function SquareSlashIcon({
- className,
- size = 24,
-}: {
- className?: string;
- size?: number;
-}) {
- return (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- width={size}
- height={size}
- className={className}
- fill="none"
- viewBox="0 0 256 256"
- >
- <rect width="18" height="18" x="3" y="3" rx="2" />
- <line x1="9" x2="15" y1="15" y2="9" />
- </svg>
- );
-}
diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts
new file mode 100644
index 00000000..2d7c684c
--- /dev/null
+++ b/apps/web/src/components/providers/global-prefetcher.ts
@@ -0,0 +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,
+ ]);
+}
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index bdd86d58..823d48a3 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -126,20 +126,24 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
className={cn(
dropdownMenuItemVariants({ variant }),
- "pl-8 pr-2",
+ "pl-2 pr-8",
className
)}
checked={checked}
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
{...props}
>
- <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
+ {children}
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
- {children}
</DropdownMenuPrimitive.CheckboxItem>
));
+
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index d4cd57a1..440191f2 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Eye, EyeOff } from "lucide-react";
+import { Eye, EyeOff, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
@@ -7,39 +7,93 @@ import { Button } from "./button";
interface InputProps extends React.ComponentProps<"input"> {
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
+ showClearIcon?: boolean;
+ onClear?: () => void;
+ containerClassName?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
- { className, type, showPassword, onShowPasswordChange, value, ...props },
+ {
+ className,
+ type,
+ containerClassName,
+ showPassword,
+ onShowPasswordChange,
+ showClearIcon,
+ onClear,
+ value,
+ onFocus,
+ onBlur,
+ ...props
+ },
ref
) => {
+ const [isFocused, setIsFocused] = React.useState(false);
+
const isPassword = type === "password";
const showPasswordToggle = isPassword && onShowPasswordChange;
+ const showClear =
+ showClearIcon &&
+ onClear &&
+ value &&
+ String(value).length > 0 &&
+ isFocused;
const inputType = isPassword && showPassword ? "text" : type;
+ const hasIcons = showPasswordToggle || showClear;
+ const iconCount = Number(showPasswordToggle) + Number(showClear);
+ const paddingRight =
+ iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
+
return (
- <div className={showPassword ? "relative w-full" : ""}>
+ <div className={cn(hasIcons ? "relative w-full" : "", containerClassName)}>
<input
type={inputType}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
- showPasswordToggle && "pr-10",
+ paddingRight,
className
)}
ref={ref}
value={value}
+ onFocus={(e) => {
+ setIsFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setIsFocused(false);
+ onBlur?.(e);
+ }}
{...props}
/>
+ {showClear && (
+ <Button
+ type="button"
+ variant="text"
+ size="icon"
+ onMouseDown={(e) => {
+ e.preventDefault();
+ onClear?.();
+ }}
+ className="absolute right-0 top-0 h-full px-3 text-muted-foreground !opacity-100"
+ aria-label="Clear input"
+ >
+ <X className="!size-[0.85]" />
+ </Button>
+ )}
{showPasswordToggle && (
<Button
type="button"
variant="text"
size="icon"
onClick={() => onShowPasswordChange?.(!showPassword)}
- className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground"
+ className={cn(
+ "absolute top-0 h-full px-3 text-muted-foreground hover:text-foreground",
+ showClear ? "right-10" : "right-0"
+ )}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
index d5aaf2ee..1fc227b0 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -1,53 +1,18 @@
-"use client";
-
import * as React from "react";
-import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
-
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
- React.ElementRef<typeof ScrollAreaPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
- type?: "auto" | "always" | "scroll" | "hover";
- showHorizontalScrollbar?: boolean;
- }
->(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => (
- <ScrollAreaPrimitive.Root
- ref={ref}
- className={cn("relative overflow-hidden", className)}
- type={type}
- {...props}
- >
- <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
- {children}
- </ScrollAreaPrimitive.Viewport>
- <ScrollBar />
- {showHorizontalScrollbar && <ScrollBar orientation="horizontal" />}
- <ScrollAreaPrimitive.Corner />
- </ScrollAreaPrimitive.Root>
-));
-ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
-
-const ScrollBar = React.forwardRef<
- React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
- React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
->(({ className, orientation = "vertical", ...props }, ref) => (
- <ScrollAreaPrimitive.ScrollAreaScrollbar
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement>
+>(({ className, children, ...props }, ref) => (
+ <div
ref={ref}
- orientation={orientation}
- className={cn(
- "flex touch-none select-none transition-colors",
- orientation === "vertical" &&
- "h-full w-2.5 border-l border-l-transparent p-px",
- orientation === "horizontal" &&
- "h-2.5 flex-col border-t border-t-transparent p-px",
- className
- )}
+ className={cn("overflow-auto scrollbar-thin", className)}
{...props}
>
- <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
- </ScrollAreaPrimitive.ScrollAreaScrollbar>
+ {children}
+ </div>
));
-ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+ScrollArea.displayName = "ScrollArea";
-export { ScrollArea, ScrollBar };
+export { ScrollArea };
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
index 8071107e..fd4ad523 100644
--- a/apps/web/src/env.ts
+++ b/apps/web/src/env.ts
@@ -15,6 +15,8 @@ export const env = createEnv({
.default("development"),
UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
+ FREESOUND_CLIENT_ID: z.string(),
+ FREESOUND_API_KEY: z.string(),
},
client: {},
runtimeEnv: {
@@ -23,5 +25,7 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
+ FREESOUND_CLIENT_ID: process.env.FREESOUND_CLIENT_ID,
+ FREESOUND_API_KEY: process.env.FREESOUND_API_KEY,
},
});
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
new file mode 100644
index 00000000..db9a7e8e
--- /dev/null
+++ b/apps/web/src/hooks/use-sound-search.ts
@@ -0,0 +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,
+ };
+}
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index e1996cf5..92f70805 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -8,9 +8,9 @@ const redis = new Redis({
token: env.UPSTASH_REDIS_REST_TOKEN,
});
-export const waitlistRateLimit = new Ratelimit({
+export const baseRateLimit = new Ratelimit({
redis,
- limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
+ limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
analytics: true,
- prefix: "waitlist-rate-limit",
+ prefix: "rate-limit",
});
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 2ff63ac7..98001591 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -9,9 +9,11 @@ import {
TimelineData,
} from "./types";
import { TimelineTrack } from "@/types/timeline";
+import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
+ private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
private config: StorageConfig;
constructor() {
@@ -19,6 +21,7 @@ class StorageService {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
timelineDb: "video-editor-timelines",
+ savedSoundsDb: "video-editor-saved-sounds",
version: 1,
};
@@ -27,6 +30,12 @@ class StorageService {
"projects",
this.config.version
);
+
+ this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>(
+ this.config.savedSoundsDb,
+ "saved-sounds",
+ this.config.version
+ );
}
// Helper to get project-specific media adapters
@@ -264,6 +273,89 @@ class StorageService {
};
}
+ async loadSavedSounds(): Promise<SavedSoundsData> {
+ try {
+ const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds");
+ return (
+ savedSoundsData || {
+ sounds: [],
+ lastModified: new Date().toISOString(),
+ }
+ );
+ } catch (error) {
+ console.error("Failed to load saved sounds:", error);
+ return { sounds: [], lastModified: new Date().toISOString() };
+ }
+ }
+
+ async saveSoundEffect(soundEffect: SoundEffect): Promise<void> {
+ try {
+ const currentData = await this.loadSavedSounds();
+
+ // Check if sound is already saved
+ if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) {
+ return; // Already saved
+ }
+
+ const savedSound: SavedSound = {
+ id: soundEffect.id,
+ name: soundEffect.name,
+ username: soundEffect.username,
+ previewUrl: soundEffect.previewUrl,
+ downloadUrl: soundEffect.downloadUrl,
+ duration: soundEffect.duration,
+ tags: soundEffect.tags,
+ license: soundEffect.license,
+ savedAt: new Date().toISOString(),
+ };
+
+ const updatedData: SavedSoundsData = {
+ sounds: [...currentData.sounds, savedSound],
+ lastModified: new Date().toISOString(),
+ };
+
+ await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ } catch (error) {
+ console.error("Failed to save sound effect:", error);
+ throw error;
+ }
+ }
+
+ async removeSavedSound(soundId: number): Promise<void> {
+ try {
+ const currentData = await this.loadSavedSounds();
+
+ const updatedData: SavedSoundsData = {
+ sounds: currentData.sounds.filter((sound) => sound.id !== soundId),
+ lastModified: new Date().toISOString(),
+ };
+
+ await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ } catch (error) {
+ console.error("Failed to remove saved sound:", error);
+ throw error;
+ }
+ }
+
+ async isSoundSaved(soundId: number): Promise<boolean> {
+ try {
+ const currentData = await this.loadSavedSounds();
+ return currentData.sounds.some((sound) => sound.id === soundId);
+ } catch (error) {
+ console.error("Failed to check if sound is saved:", error);
+ return false;
+ }
+ }
+
+ async clearSavedSounds(): Promise<void> {
+ try {
+ await this.savedSoundsAdapter.remove("user-sounds");
+ } catch (error) {
+ console.error("Failed to clear saved sounds:", error);
+ throw error;
+ }
+ }
+
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index 0cfafd47..127b1690 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -30,6 +30,7 @@ export interface StorageConfig {
projectsDb: string;
mediaDb: string;
timelineDb: string;
+ savedSoundsDb: string;
version: number;
}
diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts
deleted file mode 100644
index 43a620b0..00000000
--- a/apps/web/src/lib/waitlist.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { db, sql, waitlist } from "@opencut/db";
-
-export async function getWaitlistCount() {
- try {
- const result = await db
- .select({ count: sql<number>`count(*)` })
- .from(waitlist);
- return result[0]?.count || 0;
- } catch (error) {
- console.error("Failed to fetch waitlist count:", error);
- return 0;
- }
-}
diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts
new file mode 100644
index 00000000..215253fb
--- /dev/null
+++ b/apps/web/src/stores/sounds-store.ts
@@ -0,0 +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<boolean>;
+
+ 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<void>;
+ saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
+ removeSavedSound: (soundId: number) => Promise<void>;
+ isSoundSaved: (soundId: number) => boolean;
+ toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
+ clearSavedSounds: () => Promise<void>;
+}
+
+export const useSoundsStore = create<SoundsStore>((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/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 0f441176..71ddedca 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1445,16 +1445,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
addMediaAtTime: (item, currentTime = 0) => {
const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
-
const duration =
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
- if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return false;
+ // Get all tracks of the right type
+ const tracks = get()._tracks.filter((t) => t.type === trackType);
+
+ // Try to find a track with no overlap
+ let targetTrackId = null;
+ for (const track of tracks) {
+ if (!get().checkElementOverlap(track.id, currentTime, duration)) {
+ targetTrackId = track.id;
+ break;
+ }
+ }
+
+ // If no free track found, create a new one
+ if (!targetTrackId) {
+ targetTrackId = get().addTrack(trackType);
}
get().addElementToTrack(targetTrackId, {
diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/types/sounds.ts
new file mode 100644
index 00000000..faa485eb
--- /dev/null
+++ b/apps/web/src/types/sounds.ts
@@ -0,0 +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;
+}