diff options
| author | enkei64 <imenkei64@gmail.com> | 2025-08-04 18:11:24 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-04 18:11:24 +1000 |
| commit | 21b248718b0d1f6e7f32a8bac0cc1277241b7bd5 (patch) | |
| tree | 70d9c53fc561a9100dd4c7d1cb6d49d45e35b989 /apps/web | |
| parent | bb36b2e439bffcaa7059e3c0578a0fc61c2737ac (diff) | |
| parent | d022a78ae2330665e7d763fdc6e831a71f3b9ea1 (diff) | |
| download | opencut-21b248718b0d1f6e7f32a8bac0cc1277241b7bd5.tar.gz | |
Merge branch 'staging' into feature/panel-presets
Diffstat (limited to 'apps/web')
29 files changed, 1569 insertions, 457 deletions
diff --git a/apps/web/.env.example b/apps/web/.env.example index b21f9a92..af84fd77 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,4 +16,8 @@ UPSTASH_REDIS_REST_TOKEN=example_token # Marble Blog MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
\ No newline at end of file +NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + +# Freesound (generate at https://freesound.org/apiv2/apply/) +FREESOUND_CLIENT_ID=... +FREESOUND_API_KEY=...
\ No newline at end of file diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts new file mode 100644 index 00000000..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;
+}
|
