Merge branch 'staging' into feature/panel-presets
This commit is contained in:
commit
21b248718b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=...
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export const metadata = baseMetaData;
|
|||
|
||||
const protectedRoutes = [
|
||||
{
|
||||
path: "/api/waitlist",
|
||||
method: "POST",
|
||||
path: "/none",
|
||||
method: "GET",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface StorageConfig {
|
|||
projectsDb: string;
|
||||
mediaDb: string;
|
||||
timelineDb: string;
|
||||
savedSoundsDb: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
"strictNullChecks": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue