Merge branch 'staging' into feature/panel-presets

This commit is contained in:
enkei64 2025-08-04 18:11:24 +10:00 committed by GitHub
commit 21b248718b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1607 additions and 472 deletions

View File

@ -59,8 +59,7 @@ representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
reported to the community leaders responsible for enforcement at our discord server.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@ -87,4 +86,4 @@ version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html

View File

@ -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
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Freesound (generate at https://freesound.org/apiv2/apply/)
FREESOUND_CLIENT_ID=...
FREESOUND_API_KEY=...

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 });
}

View File

@ -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>;
}

View File

@ -235,81 +235,6 @@ export default function Editor() {
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
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}

View File

@ -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 {

View File

@ -14,8 +14,8 @@ export const metadata = baseMetaData;
const protectedRoutes = [
{
path: "/api/waitlist",
method: "POST",
path: "/none",
method: "GET",
},
];

View File

@ -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">

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 }),
}}

View File

@ -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">Its 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>
);
}

View File

@ -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={{

View File

@ -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>
);
}

View File

@ -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,
]);
}

View File

@ -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;

View File

@ -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 ? (

View File

@ -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
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-hidden", className)}
type={type}
className={cn("overflow-auto scrollbar-thin", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
{showHorizontalScrollbar && <ScrollBar orientation="horizontal" />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
{children}
</div>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
ScrollArea.displayName = "ScrollArea";
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
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
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
export { ScrollArea };

View File

@ -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,
},
});

View File

@ -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,
};
}

View File

@ -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",
});

View File

@ -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();

View File

@ -30,6 +30,7 @@ export interface StorageConfig {
projectsDb: string;
mediaDb: string;
timelineDb: string;
savedSoundsDb: string;
version: number;
}

View File

@ -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;
}
}

View File

@ -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;
}
},
}));

View File

@ -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, {

View File

@ -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;
}

View File

@ -5,6 +5,8 @@ export const keys = () =>
createEnv({
server: {
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
},
client: {
NEXT_PUBLIC_BETTER_AUTH_URL: z.string().url(),
@ -12,5 +14,7 @@ export const keys = () =>
runtimeEnv: {
NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
},
});

View File

@ -1,9 +1,20 @@
import { betterAuth } from "better-auth";
import { betterAuth, RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@opencut/db";
import { keys } from "./keys";
import { Redis } from "@upstash/redis";
const { NEXT_PUBLIC_BETTER_AUTH_URL, BETTER_AUTH_SECRET } = keys();
const {
NEXT_PUBLIC_BETTER_AUTH_URL,
BETTER_AUTH_SECRET,
UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN,
} = keys();
const redis = new Redis({
url: UPSTASH_REDIS_REST_URL,
token: UPSTASH_REDIS_REST_TOKEN,
});
export const auth = betterAuth({
database: drizzleAdapter(db, {
@ -19,6 +30,18 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
rateLimit: {
storage: "secondary-storage",
customStorage: {
get: async (key) => {
const value = await redis.get(key);
return value as RateLimit | undefined;
},
set: async (key, value) => {
await redis.set(key, value);
},
},
},
baseURL: NEXT_PUBLIC_BETTER_AUTH_URL,
appName: "OpenCut",
trustedOrigins: ["http://localhost:3000"],

View File

@ -57,11 +57,3 @@ export const verifications = pgTable("verifications", {
() => /* @__PURE__ */ new Date()
),
}).enableRLS();
export const waitlist = pgTable("waitlist", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
}).enableRLS();

View File

@ -1,5 +1,7 @@
{
"compilerOptions": {
"strictNullChecks": true
"strictNullChecks": true,
"moduleResolution": "bundler",
"module": "esnext"
}
}

View File

@ -8,7 +8,10 @@
"DATABASE_URL",
"BETTER_AUTH_SECRET",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN"
"UPSTASH_REDIS_REST_TOKEN",
"MARBLE_WORKSPACE_KEY",
"FREESOUND_CLIENT_ID",
"FREESOUND_API_KEY"
]
},
"check-types": {