Merge branch 'main' into feat/timeline-return-to-start
This commit is contained in:
commit
e71788014d
|
|
@ -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=...
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
|
|
@ -85,7 +86,7 @@
|
|||
"drizzle-kit": "^0.31.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^2.1.8"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -87,8 +87,8 @@ async function Page({ params }: PageProps) {
|
|||
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export default async function BlogPage() {
|
|||
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ export default async function ContributorsPage() {
|
|||
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
|
|
@ -138,8 +138,8 @@ export default async function ContributorsPage() {
|
|||
className="group block flex-1"
|
||||
>
|
||||
<div className="relative mx-auto max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<div className="absolute inset-0 bg-linear-to-r from-muted/50 to-muted/30 rounded-2xl blur-sm group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
|
|
@ -268,7 +268,7 @@ export default async function ContributorsPage() {
|
|||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<Card className="h-full bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<Card className="h-full bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-6 text-center h-full flex flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors">
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -32,39 +32,121 @@ export default function Editor() {
|
|||
setPropertiesPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||
const {
|
||||
activeProject,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
} = useProjectStore();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.project_id as string;
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
usePlaybackControls();
|
||||
|
||||
useEffect(() => {
|
||||
const initProject = async () => {
|
||||
if (!projectId) return;
|
||||
let isCancelled = false;
|
||||
|
||||
const initProject = async () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate initialization
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is already loaded
|
||||
if (activeProject?.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check global invalid tracking first (most important for preventing duplicates)
|
||||
if (isInvalidProjectId(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already handled this project ID locally
|
||||
if (handledProjectIds.current.has(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as initializing to prevent race conditions
|
||||
isInitializingRef.current = true;
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
return;
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Project loaded successfully
|
||||
isInitializingRef.current = false;
|
||||
} catch (error) {
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// More specific error handling - only create new project for actual "not found" errors
|
||||
const isProjectNotFound =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("not found") ||
|
||||
error.message.includes("does not exist") ||
|
||||
error.message.includes("Project not found"));
|
||||
|
||||
if (isProjectNotFound) {
|
||||
// Mark this project ID as invalid globally BEFORE creating project
|
||||
markProjectIdAsInvalid(projectId);
|
||||
|
||||
try {
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
|
||||
// Check again if component was unmounted
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createError) {
|
||||
console.error("Failed to create new project:", createError);
|
||||
}
|
||||
} else {
|
||||
// For other errors (storage issues, corruption, etc.), don't create new project
|
||||
console.error(
|
||||
"Project loading failed with recoverable error:",
|
||||
error
|
||||
);
|
||||
// Remove from handled set so user can retry
|
||||
handledProjectIds.current.delete(projectId);
|
||||
}
|
||||
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initProject();
|
||||
}, [projectId, activeProject?.id, loadProject, createNewProject, router]);
|
||||
|
||||
// Cleanup function to cancel async operations
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
|
|
@ -85,7 +167,7 @@ export default function Editor() {
|
|||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
className="h-full w-full gap-[0.19rem] px-3"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
|
|
@ -93,7 +175,7 @@ export default function Editor() {
|
|||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0"
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
|
@ -117,7 +199,7 @@ export default function Editor() {
|
|||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
|
|
@ -132,7 +214,7 @@ export default function Editor() {
|
|||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-2 pb-2"
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
|
|
|
|||
|
|
@ -1,82 +1,107 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 1rem;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 0 0% 3.9%;
|
||||
--sidebar-primary: 0 0% 9%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 0 0% 96.1%;
|
||||
--sidebar-accent-foreground: 0 0% 9%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 3.9%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 89%;
|
||||
--card: 0 0% 14.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 14.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 180 95% 40%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 100% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 17%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 0 0% 3.9%;
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-primary: 0 0% 98%;
|
||||
--sidebar-primary-foreground: 0 0% 9%;
|
||||
--sidebar-accent: 0 0% 14.9%;
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 0 0% 14.9%;
|
||||
--sidebar-ring: 0 0% 83.1%;
|
||||
--panel-background: 0 0% 11%;
|
||||
--panel-accent: 0 0% 15%;
|
||||
}
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
/* Custom colors - light mode (default) */
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(216, 8%, 86%);
|
||||
--card-foreground: hsl(0 0% 2%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(205, 84%, 47%);
|
||||
--primary-foreground: hsl(0 0% 91%);
|
||||
--secondary: hsl(216, 13%, 92%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 92%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 83%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--panel-background: hsl(216 13% 92%);
|
||||
--panel-accent: hsl(216, 8%, 86%);
|
||||
|
||||
/* Radius base */
|
||||
--radius: 1rem;
|
||||
}
|
||||
.dark {
|
||||
/* Custom colors - dark mode */
|
||||
--background: hsl(0 0% 4%);
|
||||
--foreground: hsl(0 0% 89%);
|
||||
--card: hsl(0 0% 14.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(0 0% 14.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(205, 84%, 53%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 100% 60%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 17%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 3.9%);
|
||||
--sidebar-foreground: hsl(0 0% 98%);
|
||||
--sidebar-primary: hsl(0 0% 98%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 9%);
|
||||
--sidebar-accent: hsl(0 0% 14.9%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 98%);
|
||||
--sidebar-border: hsl(0 0% 14.9%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--panel-background: hsl(0 0% 11%);
|
||||
--panel-accent: hsl(0 0% 15%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
|
@ -86,3 +111,135 @@
|
|||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-base: 0.95rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.8rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Panel */
|
||||
--color-panel: var(--panel-background);
|
||||
--color-panel-accent: var(--panel-accent);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ export const metadata = baseMetaData;
|
|||
|
||||
const protectedRoutes = [
|
||||
{
|
||||
path: "/api/waitlist",
|
||||
method: "POST",
|
||||
path: "/none",
|
||||
method: "GET",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ export default function RootLayout({
|
|||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<TooltipProvider>
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
<Analytics />
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export default function PrivacyPage() {
|
|||
<Header />
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
|
@ -47,7 +47,7 @@ export default function PrivacyPage() {
|
|||
have any questions.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
|
||||
<Card className="bg-background/80 backdrop-blur-xs border-2 border-muted/30">
|
||||
<CardContent className="p-8 text-base leading-relaxed space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ export default function ProjectsPage() {
|
|||
href="/"
|
||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="!size-5 shrink-0" />
|
||||
<ChevronLeft className="size-5! shrink-0" />
|
||||
<span className="text-sm font-medium">Back</span>
|
||||
</Link>
|
||||
<div className="block md:hidden">
|
||||
|
|
@ -153,7 +153,7 @@ export default function ProjectsPage() {
|
|||
size="sm"
|
||||
onClick={handleCancelSelection}
|
||||
>
|
||||
<X className="!size-4" />
|
||||
<X className="size-4!" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
|
|
@ -162,7 +162,7 @@ export default function ProjectsPage() {
|
|||
size="sm"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
<Trash2 className="size-4!" />
|
||||
Delete ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -192,7 +192,7 @@ export default function ProjectsPage() {
|
|||
{isSelectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancelSelection}>
|
||||
<X className="!size-4" />
|
||||
<X className="size-4!" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
|
|
@ -200,7 +200,7 @@ export default function ProjectsPage() {
|
|||
variant="destructive"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
<Trash2 className="size-4!" />
|
||||
Delete Selected ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -399,7 +399,7 @@ function ProjectCard({
|
|||
>
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-xs border flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
|
|
@ -426,7 +426,7 @@ function ProjectCard({
|
|||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||
<Video className="h-12 w-12 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -501,7 +501,7 @@ function ProjectCard({
|
|||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<Calendar className="size-4!" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -543,7 +543,7 @@ function ProjectCard({
|
|||
function CreateButton({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<Button className="flex" onClick={onClick}>
|
||||
<Plus className="!size-4" />
|
||||
<Plus className="size-4!" />
|
||||
<span className="text-sm font-medium">New project</span>
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -122,8 +122,8 @@ export default function RoadmapPage() {
|
|||
<Header />
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
|
@ -148,7 +148,7 @@ export default function RoadmapPage() {
|
|||
{roadmapItems.map((item, index) => (
|
||||
<div key={index} className="relative">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
|
||||
<span className="text-lg font-medium text-muted-foreground select-none leading-normal">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 pt-[2px]">
|
||||
|
|
@ -156,13 +156,13 @@ export default function RoadmapPage() {
|
|||
<h3 className="font-medium text-lg">{item.title}</h3>
|
||||
<Badge
|
||||
className={cn("shadow-none", {
|
||||
"!bg-green-500 text-white":
|
||||
"bg-green-500! text-white":
|
||||
item.status.type === "complete",
|
||||
"!bg-yellow-500 text-white":
|
||||
"bg-yellow-500! text-white":
|
||||
item.status.type === "pending",
|
||||
"!bg-blue-500 text-white":
|
||||
"bg-blue-500! text-white":
|
||||
item.status.type === "info",
|
||||
"!bg-foreground/10 text-accent-foreground":
|
||||
"bg-foreground/10! text-accent-foreground":
|
||||
item.status.type === "default",
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export default function TermsPage() {
|
|||
<Header />
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
|
@ -47,7 +47,7 @@ export default function TermsPage() {
|
|||
editor.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
|
||||
<Card className="bg-background/80 backdrop-blur-xs border-2 border-muted/30">
|
||||
<CardContent className="p-8 text-base leading-relaxed space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ export default function WhyNotCapcut() {
|
|||
|
||||
<main className="relative mt-12">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Button } from "./ui/button";
|
|||
import { BackgroundIcon } from "./icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { colors } from "@/data/colors";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { PipetteIcon } from "lucide-react";
|
||||
|
||||
|
|
@ -40,12 +40,12 @@ export function BackgroundSettings() {
|
|||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 border border-muted-foreground"
|
||||
className="size-4! border border-muted-foreground"
|
||||
>
|
||||
<BackgroundIcon className="!size-3" />
|
||||
<BackgroundIcon className="size-3!" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-64 overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
|
||||
<h2 className="text-sm">Background</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
|
|
@ -100,7 +100,7 @@ function ColorView({
|
|||
}) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none" />
|
||||
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" />
|
||||
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
|
||||
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
||||
<PipetteIcon className="size-4" />
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function DeleteProjectDialog({
|
|||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="text"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -1,23 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronLeft, Download } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
SquarePen,
|
||||
Trash,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
|
||||
import { useState, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "./rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "./delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject, renameProject } = useProjectStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [newName, setNewName] = useState(activeProject?.name || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { activeProject, renameProject, deleteProject } = useProjectStore();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
|
|
@ -26,72 +46,96 @@ export function EditorHeader() {
|
|||
window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
|
||||
};
|
||||
|
||||
const handleNameClick = () => {
|
||||
if (!activeProject) return;
|
||||
setNewName(activeProject.name);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleNameSave = async () => {
|
||||
const handleNameSave = async (newName: string) => {
|
||||
console.log("handleNameSave", newName);
|
||||
if (activeProject && newName.trim() && newName !== activeProject.name) {
|
||||
try {
|
||||
await renameProject(activeProject.id, newName.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
setNewName(activeProject.name);
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") handleNameSave();
|
||||
else if (e.key === "Escape") setIsEditing(false);
|
||||
const handleDelete = () => {
|
||||
if (activeProject) {
|
||||
deleteProject(activeProject.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
router.push("/projects");
|
||||
}
|
||||
};
|
||||
|
||||
const leftContent = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<div className="w-40 xl:w-60 h-9 flex items-center">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="text-sm font-medium w-full px-2 py-1 h-9 truncate"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={handleNameSave}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onFocus={(e) => e.target.select()}
|
||||
maxLength={64}
|
||||
aria-label="Project name"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium cursor-pointer hover:underline"
|
||||
title="Click to rename"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleNameClick}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-auto py-1.5 px-2.5 flex items-center justify-center"
|
||||
>
|
||||
<div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60">
|
||||
{activeProject?.name}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40">
|
||||
<Link href="/projects">
|
||||
<DropdownMenuItem className="flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Projects
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setIsRenameDialogOpen(true)}
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete Project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://discord.gg/zmR9N35cjK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FaDiscord className="h-4 w-4" />
|
||||
Discord
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={handleNameSave}
|
||||
projectName={activeProject?.name || ""}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDelete}
|
||||
projectName={activeProject?.name || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const centerContent = (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
|
||||
</span>
|
||||
<span className="text-foreground/50">/</span>
|
||||
<span className="text-foreground/50 tabular-nums">
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
|
|
@ -106,12 +150,20 @@ export function EditorHeader() {
|
|||
<KeyboardShortcutsHelp />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm">Export</span>
|
||||
<span className="text-sm pr-1">Export</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
className="h-7"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="!size-[1.1rem]" />
|
||||
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
|
|
@ -121,7 +173,7 @@ export function EditorHeader() {
|
|||
leftContent={leftContent}
|
||||
centerContent={centerContent}
|
||||
rightContent={rightContent}
|
||||
className="bg-background h-[3.2rem] px-4 items-center"
|
||||
className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
let ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing instance
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
// Clear any existing instance safely
|
||||
if (ws) {
|
||||
// Instead of immediately destroying, just set to null
|
||||
// We'll destroy it outside this function
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
|
||||
wavesurfer.current = WaveSurfer.create({
|
||||
// Create a fresh instance
|
||||
const newWaveSurfer = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: "rgba(255, 255, 255, 0.6)",
|
||||
progressColor: "rgba(255, 255, 255, 0.9)",
|
||||
|
|
@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
interact: false,
|
||||
});
|
||||
|
||||
// Assign to ref only if component is still mounted
|
||||
if (mounted) {
|
||||
wavesurfer.current = newWaveSurfer;
|
||||
} else {
|
||||
// Component unmounted during initialization, clean up
|
||||
try {
|
||||
newWaveSurfer.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
wavesurfer.current.on("ready", () => {
|
||||
newWaveSurfer.on("ready", () => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setError(false);
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.current.on("error", (err) => {
|
||||
newWaveSurfer.on("error", (err) => {
|
||||
console.error("WaveSurfer error:", err);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
|
|
@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
await wavesurfer.current.load(audioUrl);
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
if (mounted) {
|
||||
|
|
@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
initWaveSurfer();
|
||||
// First safely destroy previous instance if it exists
|
||||
if (ws) {
|
||||
// Use this pattern to safely destroy the previous instance
|
||||
const wsToDestroy = ws;
|
||||
// Detach from ref immediately
|
||||
wavesurfer.current = null;
|
||||
|
||||
// Wait a tick to destroy so any pending operations can complete
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore errors during destroy
|
||||
}
|
||||
// Only initialize new instance after destroying the old one
|
||||
if (mounted) {
|
||||
initWaveSurfer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No previous instance to clean up, initialize directly
|
||||
initWaveSurfer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Mark component as unmounted
|
||||
mounted = false;
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
wavesurfer.current = null;
|
||||
|
||||
// Store reference to current wavesurfer instance
|
||||
const wsToDestroy = wavesurfer.current;
|
||||
|
||||
// Immediately clear the ref to prevent accessing it after unmount
|
||||
wavesurfer.current = null;
|
||||
|
||||
// If we have an instance to clean up, do it safely
|
||||
if (wsToDestroy) {
|
||||
// Delay destruction to avoid race conditions
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors - they're expected
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ 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";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
audio: <AudioView />,
|
||||
sounds: <SoundsView />,
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
|
@ -43,12 +45,14 @@ export function MediaPanel() {
|
|||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
settings: <SettingsView />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-panel rounded-sm">
|
||||
<div className="h-full flex bg-panel">
|
||||
<TabBar />
|
||||
<div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,28 +9,30 @@ import {
|
|||
SlidersHorizontalIcon,
|
||||
LucideIcon,
|
||||
TypeIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "audio"
|
||||
| "sounds"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment";
|
||||
| "adjustment"
|
||||
| "settings";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
audio: {
|
||||
sounds: {
|
||||
icon: MusicIcon,
|
||||
label: "Audio",
|
||||
label: "Sounds",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
|
|
@ -60,6 +62,10 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
|||
icon: SlidersHorizontalIcon,
|
||||
label: "Adjustment",
|
||||
},
|
||||
settings: {
|
||||
icon: SettingsIcon,
|
||||
label: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
|
|
|
|||
|
|
@ -69,21 +69,20 @@ export function TabBar() {
|
|||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75",
|
||||
activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<tab.icon className="!size-[1.1rem]" />
|
||||
<span className="text-[0.65rem]">{tab.label}</span>
|
||||
<tab.icon className="size-[1.1rem]!" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -114,10 +113,10 @@ function ScrollButton({
|
|||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
|
||||
className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="!size-4 text-foreground" />
|
||||
<Icon className="size-4! text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +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"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,8 +3,18 @@
|
|||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowDown01,
|
||||
CloudUpload,
|
||||
Grid2X2,
|
||||
Image,
|
||||
List,
|
||||
Loader2,
|
||||
Music,
|
||||
Search,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
|
|
@ -14,27 +24,62 @@ import {
|
|||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaItem;
|
||||
children: React.ReactNode;
|
||||
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => onRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name"
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
|
@ -100,7 +145,7 @@ export function MediaView() {
|
|||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
let filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -115,79 +160,117 @@ export function MediaView() {
|
|||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: any;
|
||||
let valueB: any;
|
||||
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
valueA = a.name.toLowerCase();
|
||||
valueB = b.name.toLowerCase();
|
||||
break;
|
||||
case "type":
|
||||
valueA = a.type;
|
||||
valueB = b.type;
|
||||
break;
|
||||
case "duration":
|
||||
valueA = a.duration || 0;
|
||||
valueB = b.duration || 0;
|
||||
break;
|
||||
case "size":
|
||||
valueA = a.file.size;
|
||||
valueB = b.file.size;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
}, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const previewComponents = useMemo(() => {
|
||||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
filteredMediaItems.forEach((item) => {
|
||||
let preview: React.ReactNode;
|
||||
|
||||
if (item.type === "image") {
|
||||
preview = (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
preview = (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (item.type === "audio") {
|
||||
preview = (
|
||||
<div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
previews.set(item.id, preview);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => previewComponents.get(item.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -207,43 +290,152 @@ export function MediaView() {
|
|||
>
|
||||
<div className="p-3 pb-2 bg-panel">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex gap-2">
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
|
||||
className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setMediaViewMode(
|
||||
mediaViewMode === "grid" ? "list" : "grid"
|
||||
)
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="justify-center items-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<List strokeWidth={1.5} className="!size-[1.05rem]" />
|
||||
) : (
|
||||
<Grid2X2
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
disabled={isProcessing}
|
||||
className="justify-center items-center"
|
||||
>
|
||||
<ArrowDown01
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "name") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("name");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Name{" "}
|
||||
{sortBy === "name" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "type") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("type");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Type{" "}
|
||||
{sortBy === "type" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "duration") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("duration");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Duration{" "}
|
||||
{sortBy === "duration" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "size") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("size");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
File Size{" "}
|
||||
{sortBy === "size" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex-1 p-3 pt-0">
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin">
|
||||
<div className="flex-1 p-3 pt-0 w-full">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
|
|
@ -252,50 +444,105 @@ export function MediaView() {
|
|||
onClick={handleFileSelect}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
<ListView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({
|
||||
filteredMediaItems,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
}: {
|
||||
filteredMediaItems: MediaItem[];
|
||||
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{filteredMediaItems.map((item) => (
|
||||
<MediaItemWithContextMenu
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={handleRemove}
|
||||
>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
variant="card"
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({
|
||||
filteredMediaItems,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
formatDuration,
|
||||
}: {
|
||||
filteredMediaItems: MediaItem[];
|
||||
renderPreview: (item: MediaItem) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
formatDuration: (duration: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{filteredMediaItems.map((item) => (
|
||||
<MediaItemWithContextMenu
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={handleRemove}
|
||||
>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addMediaAtTime(item, currentTime)
|
||||
}
|
||||
variant="compact"
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
PropertyGroup,
|
||||
} from "../../properties-panel/property-item";
|
||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { PipetteIcon } from "lucide-react";
|
||||
import { useMemo, memo, useCallback } from "react";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
}
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="project-info" className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<ScrollArea className="flex-1">
|
||||
<TabsContent value="project-info" className="p-5 pt-0 mt-0">
|
||||
<ProjectInfoView />
|
||||
</TabsContent>
|
||||
<TabsContent value="background" className="p-4 pt-0">
|
||||
<BackgroundView />
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const { activeProject, updateProjectFps } = useProjectStore();
|
||||
const { canvasPresets, setCanvasSize } = useEditorStore();
|
||||
const { getDisplayName } = useAspectRatio();
|
||||
|
||||
const handleAspectRatioChange = (value: string) => {
|
||||
const preset = canvasPresets.find((p) => p.name === value);
|
||||
if (preset) {
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
updateProjectFps(fps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
{activeProject?.name || "Untitled project"}
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={getDisplayName()}
|
||||
onValueChange={handleAspectRatioChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{canvasPresets.map((preset) => (
|
||||
<SelectItem key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary relative overflow-hidden",
|
||||
isSelected && "border-2 border-primary"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="absolute bottom-1 left-1 right-1 text-center">
|
||||
<span className="text-xs text-white bg-black/50 px-1 rounded">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg) => (
|
||||
<div
|
||||
key={bg}
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-2 border-primary"
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect(bg)}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
const blurLevels = useMemo(
|
||||
() => [
|
||||
{ label: "Light", value: 4 },
|
||||
{ label: "Medium", value: 8 },
|
||||
{ label: "Heavy", value: 18 },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: number) => {
|
||||
await updateBackgroundType("blur", { blurIntensity });
|
||||
},
|
||||
[updateBackgroundType]
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await updateBackgroundType("color", { backgroundColor: color });
|
||||
},
|
||||
[updateBackgroundType]
|
||||
);
|
||||
|
||||
const currentBlurIntensity = activeProject?.blurIntensity || 8;
|
||||
const isBlurBackground = activeProject?.backgroundType === "blur";
|
||||
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
|
||||
const isColorBackground = activeProject?.backgroundType === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Colors" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
||||
<PipetteIcon className="size-4" />
|
||||
</div>
|
||||
<BackgroundPreviews
|
||||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Pattern Craft" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<BackgroundPreviews
|
||||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<BackgroundPreviews
|
||||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ export function TextView() {
|
|||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
||||
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,15 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
|||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { AudioPlayer } from "@/components/ui/audio-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||
import { BackgroundSettings } from "../background-settings";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { TextElementDragState } from "@/types/editor";
|
||||
|
||||
|
|
@ -234,6 +225,7 @@ export function PreviewPanel() {
|
|||
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
if (element.hidden) return;
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
|
|
@ -389,7 +381,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 }),
|
||||
}}
|
||||
|
|
@ -407,11 +399,11 @@ export function PreviewPanel() {
|
|||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
className="absolute inset-0 bg-linear-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{element.name}</p>
|
||||
<p className="text-xs text-foreground">{element.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -475,10 +467,10 @@ export function PreviewPanel() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
|
||||
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
|
||||
>
|
||||
<div className="flex-1" />
|
||||
{hasAnyElements ? (
|
||||
|
|
@ -488,7 +480,7 @@ export function PreviewPanel() {
|
|||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
backgroundColor:
|
||||
background:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
|
|
@ -620,9 +612,9 @@ function FullscreenToolbar({
|
|||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-center gap-2 p-1 pt-2 w-full text-white"
|
||||
className="flex items-center gap-2 p-1 pt-2 w-full text-foreground relative"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
|
||||
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-foreground/90">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
|
|
@ -630,7 +622,7 @@ function FullscreenToolbar({
|
|||
fps={activeProject?.fps || 30}
|
||||
onTimeChange={seek}
|
||||
disabled={!hasAnyElements}
|
||||
className="text-white/90 hover:bg-white/10"
|
||||
className="text-foreground/90 hover:bg-white/10"
|
||||
/>
|
||||
<span className="opacity-50">/</span>
|
||||
<span>
|
||||
|
|
@ -648,7 +640,7 @@ function FullscreenToolbar({
|
|||
size="icon"
|
||||
onClick={skipBackward}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0 text-white hover:text-white/80"
|
||||
className="h-auto p-0 text-foreground"
|
||||
title="Skip backward 1s"
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
|
|
@ -658,7 +650,7 @@ function FullscreenToolbar({
|
|||
size="icon"
|
||||
onClick={toggle}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0 text-white hover:text-white/80"
|
||||
className="h-auto p-0 text-foreground hover:text-foreground/80"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
|
|
@ -671,7 +663,7 @@ function FullscreenToolbar({
|
|||
size="icon"
|
||||
onClick={skipForward}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0 text-white hover:text-white/80"
|
||||
className="h-auto p-0 text-foreground hover:text-foreground/80"
|
||||
title="Skip forward 1s"
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
|
|
@ -681,7 +673,7 @@ function FullscreenToolbar({
|
|||
<div className="flex-1 flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-1 rounded-full cursor-pointer flex-1 bg-white/20",
|
||||
"relative h-1 rounded-full cursor-pointer flex-1 bg-foreground/20",
|
||||
!hasAnyElements && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={hasAnyElements ? handleTimelineClick : undefined}
|
||||
|
|
@ -690,13 +682,13 @@ function FullscreenToolbar({
|
|||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 h-full rounded-full bg-white",
|
||||
"absolute top-0 left-0 h-full rounded-full bg-foreground",
|
||||
!isDragging && "duration-100"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20"
|
||||
className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-foreground border border-black/20"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -705,11 +697,11 @@ function FullscreenToolbar({
|
|||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-white/80 hover:text-white"
|
||||
className="size-4! text-foreground/80 hover:text-foreground"
|
||||
onClick={onToggleExpanded}
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
<Expand className="size-4!" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -743,14 +735,14 @@ function FullscreenPreview({
|
|||
getTotalDuration: () => number;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col">
|
||||
<div className="fixed inset-0 z-9999 flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center bg-background">
|
||||
<div
|
||||
className="relative overflow-hidden border border-border m-3"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
backgroundColor:
|
||||
background:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? "#1a1a1a"
|
||||
: activeProject?.backgroundColor || "#1a1a1a",
|
||||
|
|
@ -775,7 +767,7 @@ function FullscreenPreview({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-black">
|
||||
<div className="p-4 bg-background">
|
||||
<FullscreenToolbar
|
||||
hasAnyElements={hasAnyElements}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
|
|
@ -806,25 +798,7 @@ function PreviewToolbar({
|
|||
toggle: () => void;
|
||||
getTotalDuration: () => number;
|
||||
}) {
|
||||
const { isPlaying, seek } = usePlaybackStore();
|
||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentPreset,
|
||||
isOriginal,
|
||||
getOriginalAspectRatio,
|
||||
getDisplayName,
|
||||
canvasPresets,
|
||||
} = useAspectRatio();
|
||||
|
||||
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
};
|
||||
|
||||
const handleOriginalSelect = () => {
|
||||
const aspectRatio = getOriginalAspectRatio();
|
||||
setCanvasSizeToOriginal(aspectRatio);
|
||||
};
|
||||
const { isPlaying } = usePlaybackStore();
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
|
|
@ -844,88 +818,30 @@ function PreviewToolbar({
|
|||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
|
||||
className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]",
|
||||
!hasAnyElements && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={getTotalDuration()}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={activeProject?.fps || 30}
|
||||
onTimeChange={seek}
|
||||
disabled={!hasAnyElements}
|
||||
/>
|
||||
<span className="opacity-50">/</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<BackgroundSettings />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
disabled={!hasAnyElements}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={handleOriginalSelect}
|
||||
className={cn("text-xs", isOriginal && "font-semibold")}
|
||||
>
|
||||
Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canvasPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.name}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
currentPreset?.name === preset.name && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{preset.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-muted-foreground"
|
||||
onClick={toggle}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="size-4!"
|
||||
onClick={onToggleExpanded}
|
||||
title="Enter fullscreen"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
<Expand className="size-4!" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 || 30).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,4 +1,6 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
|
|
@ -17,7 +19,7 @@ export function PropertyItem({
|
|||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1",
|
||||
: "flex-col gap-1.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -33,7 +35,11 @@ export function PropertyItemLabel({
|
|||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
||||
return (
|
||||
<label className={cn("text-xs text-muted-foreground", className)}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
|
|
@ -43,5 +49,36 @@ export function PropertyItemValue({
|
|||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
||||
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface PropertyGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyGroup({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: PropertyGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyItemLabel className="cursor-pointer">
|
||||
{title}
|
||||
</PropertyItemLabel>
|
||||
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
|
||||
</div>
|
||||
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
|
||||
</PropertyItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export function TextProperties({
|
|||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-[4.5rem] resize-none bg-background/50"
|
||||
className="min-h-18 resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,7 @@ export function SelectionBox({
|
|||
return (
|
||||
<div
|
||||
ref={selectionBoxRef}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--foreground) / 0.1)",
|
||||
}}
|
||||
className="absolute pointer-events-none z-50 bg-foreground/10"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function SnapIndicator({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-[90]"
|
||||
className="absolute pointer-events-none z-90"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "../../ui/button";
|
||||
import {
|
||||
Scissors,
|
||||
|
|
@ -21,6 +21,10 @@ import {
|
|||
Link,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Bookmark,
|
||||
Eye,
|
||||
MicOff,
|
||||
Mic,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -545,7 +549,7 @@ export function Timeline() {
|
|||
{/* Timeline Header with Ruler */}
|
||||
<div className="flex bg-panel sticky top-0 z-10">
|
||||
{/* Track Labels Header */}
|
||||
<div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
|
||||
<div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
|
||||
{/* Empty space */}
|
||||
<span className="text-sm font-medium text-muted-foreground opacity-0">
|
||||
.
|
||||
|
|
@ -653,6 +657,30 @@ export function Timeline() {
|
|||
);
|
||||
}).filter(Boolean);
|
||||
})()}
|
||||
|
||||
{/* Bookmark markers */}
|
||||
{(() => {
|
||||
const { activeProject } = useProjectStore.getState();
|
||||
if (!activeProject?.bookmarks?.length) return null;
|
||||
|
||||
return activeProject.bookmarks.map((bookmarkTime, i) => (
|
||||
<div
|
||||
key={`bookmark-${i}`}
|
||||
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
|
||||
style={{
|
||||
left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
usePlaybackStore.getState().seek(bookmarkTime);
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-[-1px] left-[-5px] text-primary">
|
||||
<Bookmark className="h-3 w-3 fill-primary" />
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
|
@ -664,7 +692,7 @@ export function Timeline() {
|
|||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel"
|
||||
className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel"
|
||||
data-track-labels
|
||||
>
|
||||
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
|
||||
|
|
@ -672,17 +700,24 @@ export function Timeline() {
|
|||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
||||
className="flex items-center px-3 group"
|
||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="flex items-center justify-end flex-1 min-w-0 gap-2">
|
||||
{track.muted ? (
|
||||
<MicOff
|
||||
className="h-4 w-4 text-destructive cursor-pointer"
|
||||
onClick={() => toggleTrackMute(track.id)}
|
||||
/>
|
||||
) : (
|
||||
<Mic
|
||||
className="h-4 w-4 text-muted-foreground cursor-pointer"
|
||||
onClick={() => toggleTrackMute(track.id)}
|
||||
/>
|
||||
)}
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
{track.muted && (
|
||||
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -713,12 +748,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={{
|
||||
|
|
@ -737,7 +767,7 @@ export function Timeline() {
|
|||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${getCumulativeHeightBefore(
|
||||
tracks,
|
||||
|
|
@ -763,7 +793,7 @@ export function Timeline() {
|
|||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[200]">
|
||||
<ContextMenuContent className="z-200">
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -793,13 +823,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
|
|||
return (
|
||||
<>
|
||||
{track.type === "media" && (
|
||||
<Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<Video className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{track.type === "text" && (
|
||||
<TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{track.type === "audio" && (
|
||||
<Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
<Music className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -832,6 +862,7 @@ function TimelineToolbar({
|
|||
toggleRippleEditing,
|
||||
} = useTimelineStore();
|
||||
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
|
||||
const { toggleBookmark, isBookmarked } = useProjectStore();
|
||||
|
||||
// Action handlers
|
||||
const handleSplitSelected = () => {
|
||||
|
|
@ -961,6 +992,13 @@ function TimelineToolbar({
|
|||
const handleZoomSliderChange = (values: number[]) => {
|
||||
setZoomLevel(values[0]);
|
||||
};
|
||||
|
||||
const handleToggleBookmark = async () => {
|
||||
await toggleBookmark(currentTime);
|
||||
};
|
||||
|
||||
// Check if the current time is bookmarked
|
||||
const currentBookmarked = isBookmarked(currentTime);
|
||||
return (
|
||||
<div className="border-b flex items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
|
|
@ -1108,6 +1146,19 @@ function TimelineToolbar({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
||||
<Bookmark
|
||||
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -1141,6 +1192,8 @@ function TimelineToolbar({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="h-6 w-px bg-border mx-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="text" size="icon" onClick={handleZoomOut}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,41 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../ui/button";
|
||||
import {
|
||||
MoreVertical,
|
||||
Scissors,
|
||||
Trash2,
|
||||
SplitSquareHorizontal,
|
||||
Music,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Type,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "../audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
||||
import { TimelineElementProps } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
import {
|
||||
getTrackElementClasses,
|
||||
TIMELINE_CONSTANTS,
|
||||
getTrackHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "../../ui/dropdown-menu";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -60,30 +46,27 @@ export function TimelineElement({
|
|||
removeElementFromTrackWithRipple,
|
||||
dragState,
|
||||
splitElement,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
addElementToTrack,
|
||||
replaceElementMedia,
|
||||
rippleEditingEnabled,
|
||||
toggleElementHidden,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaItems.find((item) => item.id === element.mediaId)
|
||||
: null;
|
||||
const isAudio = mediaItem?.type === "audio";
|
||||
|
||||
const {
|
||||
resizing,
|
||||
isResizing,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd,
|
||||
} = useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onUpdateTrim: updateElementTrim,
|
||||
onUpdateDuration: updateElementDuration,
|
||||
});
|
||||
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||
useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onUpdateTrim: updateElementTrim,
|
||||
onUpdateDuration: updateElementDuration,
|
||||
});
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
|
|
@ -141,6 +124,11 @@ export function TimelineElement({
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleElementHidden = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleElementHidden(track.id, element.id);
|
||||
};
|
||||
|
||||
const handleReplaceClip = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (element.type !== "media") {
|
||||
|
|
@ -177,9 +165,7 @@ export function TimelineElement({
|
|||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.content}
|
||||
</span>
|
||||
<span className="text-xs text-white truncate">{element.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -196,86 +182,36 @@ export function TimelineElement({
|
|||
|
||||
const TILE_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
if (mediaItem.type === "image") {
|
||||
if (
|
||||
mediaItem.type === "image" ||
|
||||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
|
||||
) {
|
||||
// Calculate tile size based on 16:9 aspect ratio
|
||||
const trackHeight = getTrackHeight(track.type);
|
||||
const tileHeight = trackHeight - 8; // Account for padding
|
||||
const tileHeight = trackHeight;
|
||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||
|
||||
const imageUrl =
|
||||
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
|
||||
const isImage = mediaItem.type === "image";
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
||||
{/* Background with tiled images */}
|
||||
<div
|
||||
className={`w-full h-full relative ${
|
||||
isSelected ? "bg-primary" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0"
|
||||
className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`}
|
||||
style={{
|
||||
backgroundImage: mediaItem.url
|
||||
? `url(${mediaItem.url})`
|
||||
: "none",
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled background of ${mediaItem.name}`}
|
||||
/>
|
||||
{/* Overlay with vertical borders */}
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0px,
|
||||
transparent ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||
)`,
|
||||
backgroundPosition: "left center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VIDEO_TILE_PADDING = 16;
|
||||
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||
const trackHeight = getTrackHeight(track.type);
|
||||
const tileHeight = trackHeight - 8; // Match image padding
|
||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
||||
{/* Background with tiled thumbnails */}
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0"
|
||||
style={{
|
||||
backgroundImage: mediaItem.thumbnailUrl
|
||||
? `url(${mediaItem.thumbnailUrl})`
|
||||
: "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
||||
/>
|
||||
{/* Overlay with vertical borders */}
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0px,
|
||||
transparent ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||
)`,
|
||||
backgroundPosition: "left center",
|
||||
}}
|
||||
aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,9 +266,9 @@ export function TimelineElement({
|
|||
<div
|
||||
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
||||
track.type
|
||||
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
||||
)} ${isSelected ? "" : ""} ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
} ${element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||
onMouseDown={handleElementMouseDown}
|
||||
onContextMenu={(e) =>
|
||||
|
|
@ -343,14 +279,24 @@ export function TimelineElement({
|
|||
{renderElementContent()}
|
||||
</div>
|
||||
|
||||
{element.hidden && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
|
||||
{isAudio ? (
|
||||
<VolumeX className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
||||
className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary z-50"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
||||
className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -358,11 +304,34 @@ export function TimelineElement({
|
|||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[200]">
|
||||
<ContextMenuContent className="z-200">
|
||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleToggleElementHidden}>
|
||||
{isAudio ? (
|
||||
element.hidden ? (
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 mr-2" />
|
||||
)
|
||||
) : element.hidden ? (
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
<span>
|
||||
{isAudio
|
||||
? element.hidden
|
||||
? "Unmute"
|
||||
: "Mute"
|
||||
: element.hidden
|
||||
? "Show"
|
||||
: "Hide"}{" "}
|
||||
{element.type === "text" ? "text" : "clip"}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function TimelinePlayhead({
|
|||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
const totalHeight = timelineContainerHeight - 4;
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
|
|
@ -126,7 +126,7 @@ export function TimelinePlayhead({
|
|||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-[150]"
|
||||
className="absolute pointer-events-auto z-150"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
|
|
@ -137,12 +137,12 @@ export function TimelinePlayhead({
|
|||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,45 +68,51 @@ export function TimelineTrackContent({
|
|||
elementDuration: number,
|
||||
excludeElementId?: string
|
||||
) => {
|
||||
if (!snappingEnabled) {
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
return snapTimeToFrame(dropTime, projectFps);
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
let finalTime = snapTimeToFrame(dropTime, projectFps);
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Try snapping both start and end edges for drops
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Try snapping both start and end edges for drops
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
return bestSnapResult.snappedTime;
|
||||
return finalTime;
|
||||
};
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -148,9 +154,13 @@ export function TimelineTrackContent({
|
|||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
|
||||
// Apply snapping if enabled
|
||||
let finalTime = adjustedTime;
|
||||
// Always apply frame snapping first
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
let finalTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
let snapPoint = null;
|
||||
|
||||
// Additionally apply element snapping if enabled
|
||||
if (snappingEnabled) {
|
||||
// Find the element being dragged to get its duration
|
||||
let elementDuration = 5; // fallback duration
|
||||
|
|
@ -196,18 +206,16 @@ export function TimelineTrackContent({
|
|||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
snapPoint = bestSnapResult.snapPoint;
|
||||
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
|
||||
if (bestSnapResult.snapPoint) {
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
snapPoint = bestSnapResult.snapPoint;
|
||||
}
|
||||
|
||||
// Notify parent component about snap point change
|
||||
onSnapPointChange?.(snapPoint);
|
||||
} else {
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
finalTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
|
||||
// Clear snap point when not snapping
|
||||
// Clear snap point when element snapping is disabled
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,10 +93,10 @@ const EditableShortcutKey = ({
|
|||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
|
||||
className={`inline-flex font-sans text-xs rounded px-2 min-w-6 min-h-6 leading-none items-center justify-center shadow-xs border mr-1 cursor-pointer hover:bg-opacity-80 ${
|
||||
isRecording
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-white/10 bg-black/20"
|
||||
: "border bg-accent"
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
title={
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ export function Handlebars({ children }: HandlebarsProps) {
|
|||
const rightGradient = useTransform(rightHandleX, [0, width + 10], [0, 100]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div ref={containerRef} className="relative -rotate-[2.76deg] mt-2">
|
||||
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-[1]">
|
||||
<div className="flex justify-center gap-4 leading-16">
|
||||
<div ref={containerRef} className="relative -rotate-[2.76deg] mt-0.5">
|
||||
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-1">
|
||||
<motion.div
|
||||
className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function Hero() {
|
|||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
className="mb-8 flex justify-center"
|
||||
className="mb-4 flex justify-center"
|
||||
>
|
||||
<SponsorButton
|
||||
href="https://vercel.com/home?utm_source=opencut"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function Onboarding() {
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px] !outline-none">
|
||||
<DialogContent className="sm:max-w-[425px] outline-hidden!">
|
||||
<DialogTitle>
|
||||
<span className="sr-only">{getStepTitle()}</span>
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ export function RenameProjectDialog({
|
|||
}
|
||||
}}
|
||||
placeholder="Enter a new name"
|
||||
className="mt-4"
|
||||
className="mt-4 bg-background/50"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
"border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,20 +5,21 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-foreground text-background shadow hover:bg-foreground/90",
|
||||
default:
|
||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better)
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const ChartContainer = React.forwardRef<
|
|||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -184,7 +184,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -213,7 +213,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow-sm focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
|||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -115,7 +115,7 @@ const CommandItem = React.forwardRef<
|
|||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const contextMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -64,7 +64,7 @@ const ContextMenuSubContent = React.forwardRef<
|
|||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -80,7 +80,7 @@ const ContextMenuContent = React.forwardRef<
|
|||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[100] bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-100 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-150 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
|
||||
className
|
||||
)}
|
||||
onCloseAutoFocus={(e) => {
|
||||
|
|
@ -55,7 +55,7 @@ const DialogContent = React.forwardRef<
|
|||
<ScrollArea className="max-h-[75vh]">
|
||||
<div className="p-6 space-y-4">{children}</div>
|
||||
</ScrollArea>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface DraggableMediaItemProps {
|
|||
showPlusOnDrag?: boolean;
|
||||
showLabel?: boolean;
|
||||
rounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
}
|
||||
|
||||
export function DraggableMediaItem({
|
||||
|
|
@ -37,6 +38,7 @@ export function DraggableMediaItem({
|
|||
showPlusOnDrag = true,
|
||||
showLabel = true,
|
||||
rounded = true,
|
||||
variant = "card",
|
||||
}: DraggableMediaItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
|
|
@ -88,49 +90,69 @@ export function DraggableMediaItem({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div ref={dragRef} className="relative group w-28 h-28">
|
||||
<div
|
||||
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
{variant === "card" ? (
|
||||
<div ref={dragRef} className="relative group w-28 h-28">
|
||||
<div
|
||||
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||
)}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
<span
|
||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
>
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={dragRef} className="relative group w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||
"h-10 flex items-center gap-3 cursor-default w-full",
|
||||
"[&::-webkit-drag-ghost]:opacity-0",
|
||||
className
|
||||
)}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
<span
|
||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
>
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="text-sm truncate flex-1 w-full">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom drag preview */}
|
||||
{isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-[9999]"
|
||||
className="fixed pointer-events-none z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
||||
|
|
@ -139,7 +161,7 @@ export function DraggableMediaItem({
|
|||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary"
|
||||
className="relative rounded-md overflow-hidden shadow-2xl ring-3 ring-primary"
|
||||
>
|
||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||
{preview}
|
||||
|
|
@ -171,7 +193,10 @@ function PlusButton({
|
|||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
||||
className={cn(
|
||||
"absolute bottom-2 right-2 size-4 bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -179,7 +204,7 @@ function PlusButton({
|
|||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus className="!size-3" />
|
||||
<Plus className="size-3!" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -65,7 +65,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const InputOTP = React.forwardRef<
|
|||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50",
|
||||
"flex items-center justify-between gap-2 w-full has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
|
|
@ -45,7 +45,7 @@ const InputOTPSlot = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
"relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,37 +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(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
showPasswordToggle && "pr-10",
|
||||
"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",
|
||||
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 ? (
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const Menubar = React.forwardRef<
|
|||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const MenubarTrigger = React.forwardRef<
|
|||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -55,7 +55,7 @@ const MenubarSubTrigger = React.forwardRef<
|
|||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
|
|
@ -74,7 +74,7 @@ const MenubarSubContent = React.forwardRef<
|
|||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -97,7 +97,7 @@ const MenubarContent = React.forwardRef<
|
|||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -116,7 +116,7 @@ const MenubarItem = React.forwardRef<
|
|||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
|
|
@ -132,7 +132,7 @@ const MenubarCheckboxItem = React.forwardRef<
|
|||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
@ -155,7 +155,7 @@ const MenubarRadioItem = React.forwardRef<
|
|||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
|||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
|
|
@ -55,7 +55,7 @@ const NavigationMenuTrigger = React.forwardRef<
|
|||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
className="relative top-px ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
|
|
@ -86,7 +86,7 @@ const NavigationMenuViewport = React.forwardRef<
|
|||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-(--radix-navigation-menu-viewport-width)",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
@ -104,7 +104,7 @@ const NavigationMenuIndicator = React.forwardRef<
|
|||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
|
|||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow-sm focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const ResizableHandle = ({
|
|||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -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-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
export { ScrollArea };
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group;
|
|||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -35,14 +35,14 @@ const SelectTrigger = React.forwardRef<
|
|||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex gap-1 h-7 cursor-pointer w-auto items-center justify-between whitespace-nowrap rounded-md bg-accent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
|
|
@ -91,7 +91,7 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
|
@ -108,7 +108,7 @@ const SelectContent = React.forwardRef<
|
|||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const Separator = React.forwardRef<
|
|||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const SheetContent = React.forwardRef<
|
|||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef<
|
|||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
@ -181,7 +181,7 @@ const Sidebar = React.forwardRef<
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
@ -198,7 +198,7 @@ const Sidebar = React.forwardRef<
|
|||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
|
|
@ -225,30 +225,30 @@ const Sidebar = React.forwardRef<
|
|||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 relative h-full w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
||||
"duration-200 relative h-full w-(--sidebar-width) bg-transparent transition-[width] ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
||||
"duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-(--sidebar-width) transition-[left,right,width] ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 border-r group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 border-l group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -301,9 +301,9 @@ const SidebarRail = React.forwardRef<
|
|||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
|
|
@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -439,7 +439,7 @@ const SidebarGroupLabel = React.forwardRef<
|
|||
ref={ref as any}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
|
|
@ -460,9 +460,9 @@ const SidebarGroupAction = React.forwardRef<
|
|||
ref={ref as any}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
|
|
@ -512,18 +512,18 @@ const SidebarMenuItem = React.forwardRef<
|
|||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -606,9 +606,9 @@ const SidebarMenuAction = React.forwardRef<
|
|||
ref={ref as any}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
|
|
@ -669,7 +669,7 @@ const SidebarMenuSkeleton = React.forwardRef<
|
|||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||
className="h-4 flex-1 max-w-(--skeleton-width)"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
|
|
@ -722,7 +722,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
|||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const Slider = React.forwardRef<
|
|||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function SponsorButton({
|
|||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-xs hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors">
|
||||
Sponsored by
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const TableFooter = React.forwardRef<
|
|||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
"border-t bg-muted/50 font-medium last:[&>tr]:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
|
|||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
|||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
|
|||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
|
|||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive",
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-hidden focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
|
|||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground group-[.destructive]:hover:text-red-50 group-[.destructive]:focus-visible:ring-red-400 group-[.destructive]:focus-visible:ring-offset-red-600",
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground hover:group-[.destructive]:text-red-50 focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||
import { cn } from "../../lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
|
|
|
|||
|
|
@ -7,18 +7,18 @@ export const TRACK_COLORS: Record<
|
|||
> = {
|
||||
media: {
|
||||
solid: "bg-blue-500",
|
||||
background: "bg-blue-500/20",
|
||||
border: "border-white/80",
|
||||
background: "",
|
||||
border: "",
|
||||
},
|
||||
text: {
|
||||
solid: "bg-[#9C4937]",
|
||||
background: "bg-[#9C4937]",
|
||||
border: "border-white/80",
|
||||
solid: "bg-[#5DBAA0]",
|
||||
background: "bg-[#5DBAA0]",
|
||||
border: "",
|
||||
},
|
||||
audio: {
|
||||
solid: "bg-green-500",
|
||||
background: "bg-green-500/20",
|
||||
border: "border-white/80",
|
||||
background: "bg-[#915DBE]",
|
||||
border: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export function getTrackElementClasses(type: TrackType) {
|
|||
|
||||
// Track height definitions
|
||||
export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
||||
media: 65,
|
||||
media: 60,
|
||||
text: 25,
|
||||
audio: 50,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
// These are the gradients from Pattern Craft (https://patterncraft.fun/)
|
||||
|
||||
export const patternCraftGradients = [
|
||||
// Dreamy Sky Pink Glow
|
||||
"radial-gradient(circle at 30% 70%, rgba(173, 216, 230, 0.35), transparent 60%), radial-gradient(circle at 70% 30%, rgba(255, 182, 193, 0.4), transparent 60%)",
|
||||
|
||||
// Soft Warm Pastel Texture
|
||||
"radial-gradient(circle at 20% 80%, rgba(255, 182, 153, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 244, 214, 0.5) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(255, 182, 153, 0.1) 0%, transparent 50%), #fff8f0",
|
||||
|
||||
// Warm Soft Coral & Cream
|
||||
"radial-gradient(circle at 20% 80%, rgba(255, 160, 146, 0.25) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 244, 228, 0.3) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(255, 160, 146, 0.15) 0%, transparent 50%), #fef9f7",
|
||||
|
||||
// Soft Green Glow
|
||||
"radial-gradient(circle at center, #8FFFB0, transparent), white",
|
||||
|
||||
// Purple Glow Right
|
||||
"radial-gradient(circle at top right, rgba(173, 109, 244, 0.5), transparent 70%)",
|
||||
|
||||
// Teal Glow Right
|
||||
"radial-gradient(circle at top right, rgba(56, 193, 182, 0.5), transparent 70%)",
|
||||
|
||||
// Warm Orange Glow Right
|
||||
"radial-gradient(circle at top right, rgba(255, 140, 60, 0.5), transparent 70%)",
|
||||
|
||||
// Cool Blue Glow Right
|
||||
"radial-gradient(circle at top right, rgba(70, 130, 180, 0.5), transparent 70%)",
|
||||
|
||||
// Purple Glow Left
|
||||
"radial-gradient(circle at top left, rgba(173, 109, 244, 0.5), transparent 70%)",
|
||||
];
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
|
||||
|
||||
export const syntaxUIGradients = [
|
||||
// Cyan to Blue gradients
|
||||
"linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
|
||||
"linear-gradient(to right, #bfdbfe, #a5f3fc)",
|
||||
"linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
|
||||
|
||||
// Purple gradients
|
||||
"linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
|
||||
"linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
|
||||
|
||||
// Blue gradients
|
||||
"linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
|
||||
"linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
|
||||
|
||||
// Green gradients
|
||||
"linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
|
||||
"linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
|
||||
|
||||
// Red gradient
|
||||
"linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
|
||||
|
||||
// Yellow/Orange gradient
|
||||
"linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
|
||||
|
||||
// Pink gradient
|
||||
"linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
|
||||
|
||||
// Neon radial gradient
|
||||
"radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
|
|||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { snapTimeToFrame } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
|
|
@ -109,6 +111,10 @@ export function useTimelineElementResize({
|
|||
// Reasonable sensitivity for resize operations - similar to timeline scale
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
// Get project FPS for frame snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - different behavior for media vs text/image elements
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
|
|
@ -116,9 +122,15 @@ export function useTimelineElementResize({
|
|||
|
||||
if (calculated >= 0) {
|
||||
// Normal trimming within available content
|
||||
const newTrimStart = Math.min(maxAllowed, calculated);
|
||||
const newTrimStart = snapTimeToFrame(
|
||||
Math.min(maxAllowed, calculated),
|
||||
projectFps
|
||||
);
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = element.startTime + trimDelta;
|
||||
const newStartTime = snapTimeToFrame(
|
||||
element.startTime + trimDelta,
|
||||
projectFps
|
||||
);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
|
|
@ -135,8 +147,14 @@ export function useTimelineElementResize({
|
|||
const extensionAmount = Math.abs(calculated);
|
||||
const maxExtension = element.startTime;
|
||||
const actualExtension = Math.min(extensionAmount, maxExtension);
|
||||
const newStartTime = element.startTime - actualExtension;
|
||||
const newDuration = element.duration + actualExtension;
|
||||
const newStartTime = snapTimeToFrame(
|
||||
element.startTime - actualExtension,
|
||||
projectFps
|
||||
);
|
||||
const newDuration = snapTimeToFrame(
|
||||
element.duration + actualExtension,
|
||||
projectFps
|
||||
);
|
||||
|
||||
// Keep trimStart at 0 and extend the element
|
||||
updateElementTrim(
|
||||
|
|
@ -152,7 +170,10 @@ export function useTimelineElementResize({
|
|||
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = element.startTime + trimDelta;
|
||||
const newStartTime = snapTimeToFrame(
|
||||
element.startTime + trimDelta,
|
||||
projectFps
|
||||
);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
|
|
@ -173,7 +194,10 @@ export function useTimelineElementResize({
|
|||
if (canExtendElementDuration()) {
|
||||
// Extend the duration instead of reducing trimEnd further
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = element.duration + extensionNeeded;
|
||||
const newDuration = snapTimeToFrame(
|
||||
element.duration + extensionNeeded,
|
||||
projectFps
|
||||
);
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
|
|
@ -197,14 +221,34 @@ export function useTimelineElementResize({
|
|||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
|
||||
const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
|
||||
// Calculate the desired end time based on mouse movement
|
||||
const currentEndTime =
|
||||
element.startTime +
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
element.trimEnd;
|
||||
const desiredEndTime = currentEndTime + deltaTime;
|
||||
|
||||
// Snap the desired end time to frame
|
||||
const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps);
|
||||
|
||||
// Calculate what trimEnd should be to achieve this snapped end time
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
element.duration -
|
||||
element.trimStart -
|
||||
(snappedEndTime - element.startTime)
|
||||
);
|
||||
|
||||
// Ensure we don't trim more than available content (leave at least 0.1s visible)
|
||||
const maxTrimEnd = element.duration - element.trimStart - 0.1;
|
||||
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd,
|
||||
element.trimStart,
|
||||
finalTrimEnd,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -63,6 +72,8 @@ class StorageService {
|
|||
backgroundColor: project.backgroundColor,
|
||||
backgroundType: project.backgroundType,
|
||||
blurIntensity: project.blurIntensity,
|
||||
bookmarks: project.bookmarks,
|
||||
fps: project.fps,
|
||||
};
|
||||
|
||||
await this.projectsAdapter.set(project.id, serializedProject);
|
||||
|
|
@ -83,6 +94,8 @@ class StorageService {
|
|||
backgroundColor: serializedProject.backgroundColor,
|
||||
backgroundType: serializedProject.backgroundType,
|
||||
blurIntensity: serializedProject.blurIntensity,
|
||||
bookmarks: serializedProject.bookmarks,
|
||||
fps: serializedProject.fps,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -260,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;
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export interface StorageConfig {
|
|||
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
bookmarks?: number[];
|
||||
};
|
||||
|
||||
// Extend FileSystemDirectoryHandle with missing async iterator methods
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,12 +17,15 @@ interface PanelState {
|
|||
mainContent: number;
|
||||
timeline: number;
|
||||
|
||||
mediaViewMode: "grid" | "list";
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
|
|
@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()(
|
|||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
|
||||
mediaViewMode: "grid" as const,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||
setMainContent: (size) => set({ mainContent: size }),
|
||||
setTimeline: (size) => set({ timeline: size }),
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { create } from "zustand";
|
||||
import type { PlaybackState, PlaybackControls } from "@/types/playback";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
|
||||
interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
setDuration: (duration: number) => void;
|
||||
|
|
@ -20,13 +22,33 @@ const startTimer = (store: () => PlaybackStore) => {
|
|||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
if (newTime >= state.duration) {
|
||||
// When video completes, pause and reset playhead to start
|
||||
|
||||
// Get actual content duration from timeline store
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
|
||||
// Stop at actual content end, not timeline duration (which has 10s minimum)
|
||||
// It was either this or reducing default min timeline to 1 second
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (newTime >= effectiveDuration) {
|
||||
// When content completes, pause just before the end so we can see the last frame
|
||||
const projectFps = useProjectStore.getState().activeProject?.fps;
|
||||
if (!projectFps)
|
||||
console.error("Project FPS is not set, assuming 30fps");
|
||||
|
||||
const frameOffset = 1 / (projectFps ?? 30); // Stop 1 frame before end based on project FPS
|
||||
const stopTime = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
state.pause();
|
||||
state.setCurrentTime(0);
|
||||
// Notify video elements to sync with reset
|
||||
state.setCurrentTime(stopTime);
|
||||
// Notify video elements to sync with end position
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", { detail: { time: 0 } })
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface ProjectStore {
|
|||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
invalidProjectIds?: Set<string>;
|
||||
|
||||
// Actions
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
|
|
@ -28,10 +29,20 @@ interface ProjectStore {
|
|||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
|
||||
// Bookmark methods
|
||||
toggleBookmark: (time: number) => Promise<void>;
|
||||
isBookmarked: (time: number) => boolean;
|
||||
removeBookmark: (time: number) => Promise<void>;
|
||||
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string
|
||||
) => TProject[];
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => boolean;
|
||||
markProjectIdAsInvalid: (id: string) => void;
|
||||
clearInvalidProjectIds: () => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
|
|
@ -39,6 +50,98 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
invalidProjectIds: new Set<string>(),
|
||||
|
||||
// Implementation of bookmark methods
|
||||
toggleBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const bookmarks = activeProject.bookmarks || [];
|
||||
let updatedBookmarks: number[];
|
||||
|
||||
// Check if already bookmarked
|
||||
const bookmarkIndex = bookmarks.findIndex(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
|
||||
if (bookmarkIndex !== -1) {
|
||||
// Remove bookmark
|
||||
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
|
||||
} else {
|
||||
// Add bookmark
|
||||
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to update bookmarks", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
isBookmarked: (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return false;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
return activeProject.bookmarks.some(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
},
|
||||
|
||||
removeBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const updatedBookmarks = activeProject.bookmarks.filter(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001
|
||||
);
|
||||
|
||||
if (updatedBookmarks.length === activeProject.bookmarks.length) {
|
||||
// No bookmark found to remove
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to remove bookmark", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
|
|
@ -50,6 +153,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
bookmarks: [],
|
||||
fps: 30,
|
||||
};
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
|
@ -230,9 +335,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
...project, // Copy all properties from the original project
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
|
@ -357,4 +462,23 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||
|
||||
return sortedProjects;
|
||||
},
|
||||
|
||||
// Global invalid project ID tracking implementation
|
||||
isInvalidProjectId: (id: string) => {
|
||||
const invalidIds = get().invalidProjectIds || new Set();
|
||||
return invalidIds.has(id);
|
||||
},
|
||||
|
||||
markProjectIdAsInvalid: (id: string) => {
|
||||
set((state) => ({
|
||||
invalidProjectIds: new Set([
|
||||
...(state.invalidProjectIds || new Set()),
|
||||
id,
|
||||
]),
|
||||
}));
|
||||
},
|
||||
|
||||
clearInvalidProjectIds: () => {
|
||||
set({ invalidProjectIds: new Set() });
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -126,6 +126,7 @@ interface TimelineStore {
|
|||
pushHistory?: boolean
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
toggleElementHidden: (trackId: string, elementId: string) => void;
|
||||
|
||||
// Split operations for elements
|
||||
splitElement: (
|
||||
|
|
@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||
);
|
||||
},
|
||||
|
||||
toggleElementHidden: (trackId, elementId) => {
|
||||
get().pushHistory();
|
||||
updateTracksAndSave(
|
||||
get()._tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === elementId
|
||||
? { ...element, hidden: !element.hidden }
|
||||
: element
|
||||
),
|
||||
}
|
||||
: track
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
updateTextElement: (trackId, elementId, updates) => {
|
||||
get().pushHistory();
|
||||
updateTracksAndSave(
|
||||
|
|
@ -1426,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, {
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export interface TProject {
|
|||
backgroundType?: "color" | "blur";
|
||||
blurIntensity?: number; // in pixels (4, 8, 18)
|
||||
fps?: number;
|
||||
bookmarks?: number[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ interface BaseTimelineElement {
|
|||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// Media element that references MediaStore
|
||||
|
|
|
|||
|
|
@ -1,145 +0,0 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/constants/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: "480px",
|
||||
},
|
||||
fontSize: {
|
||||
base: "0.95rem",
|
||||
xs: "0.80rem",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
panel: {
|
||||
DEFAULT: "hsl(var(--panel-background))",
|
||||
accent: "hsl(var(--panel-accent))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 8px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: {
|
||||
height: "0",
|
||||
},
|
||||
to: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
},
|
||||
"accordion-up": {
|
||||
from: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
to: {
|
||||
height: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwindcss-animate"),
|
||||
({
|
||||
addUtilities,
|
||||
}: {
|
||||
addUtilities: (utilities: Record<string, any>) => void;
|
||||
}) => {
|
||||
addUtilities({
|
||||
".scrollbar-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
".scrollbar-x-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar:horizontal": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
".scrollbar-y-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar:vertical": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
258
bun.lock
258
bun.lock
|
|
@ -73,6 +73,7 @@
|
|||
"zustand": "^5.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.1.0",
|
||||
|
|
@ -82,7 +83,7 @@
|
|||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
|
|
@ -116,9 +117,14 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
],
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.2.5", "", { "dependencies": { "typescript": "^5.8.2", "uncrypto": "^0.1.3" } }, "sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ=="],
|
||||
|
|
@ -273,7 +279,7 @@
|
|||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
|
||||
|
|
@ -307,12 +313,6 @@
|
|||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@opencut/auth": ["@opencut/auth@workspace:packages/auth"],
|
||||
|
||||
"@opencut/db": ["@opencut/db@workspace:packages/db"],
|
||||
|
|
@ -327,8 +327,6 @@
|
|||
|
||||
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.4.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.4.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
|
@ -499,6 +497,36 @@
|
|||
|
||||
"@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.8", "", { "dependencies": { "@t3-oss/env-core": "0.13.8" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-QmTLnsdQJ8BiQad2W2nvV6oUpH4oMZMqnFEjhVpzU0h3sI9hn8zb8crjWJ1Amq453mGZs6A4v4ihIeBFDOrLeQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||
|
|
@ -575,16 +603,6 @@
|
|||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
|
||||
|
|
@ -593,28 +611,18 @@
|
|||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"better-auth": ["better-auth@1.3.2", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-510kOtFBTdp4z51hWtTEqk9yqSinXzyg7PkDFnXYMq1K0KvdXRY1A9t9J998i0CSf/tJA0wNoN3S8exkOgBvTw=="],
|
||||
|
||||
"better-call": ["better-call@1.0.12", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
|
@ -631,7 +639,7 @@
|
|||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
|
|
@ -713,10 +721,6 @@
|
|||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
|
@ -725,15 +729,13 @@
|
|||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||
|
||||
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
|
||||
|
||||
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
|
|
@ -755,35 +757,21 @@
|
|||
|
||||
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
|
||||
|
|
@ -827,29 +815,15 @@
|
|||
|
||||
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
||||
|
||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
|
|
@ -861,9 +835,27 @@
|
|||
|
||||
"libphonenumber-js": ["libphonenumber-js@1.12.10", "", {}, "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
|
|
@ -879,8 +871,6 @@
|
|||
|
||||
"loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
|
@ -901,8 +891,6 @@
|
|||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
|
@ -945,12 +933,12 @@
|
|||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
|
||||
"motion": ["motion@12.23.6", "", { "dependencies": { "framer-motion": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6U55IW5i6Vut2ryKEhrZKg55490k9d6qdGXZoNSf98oQgDj5D7bqTnVJotQ6UW3AS6QfbW6KSLa7/e1gy+a07g=="],
|
||||
|
||||
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
|
||||
|
|
@ -959,8 +947,6 @@
|
|||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
|
||||
|
|
@ -969,26 +955,16 @@
|
|||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"opencut": ["opencut@workspace:apps/web"],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
|
@ -1013,24 +989,10 @@
|
|||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
|
||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
|
@ -1049,8 +1011,6 @@
|
|||
|
||||
"pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"radix-ui": ["radix-ui@1.4.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.7", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-menubar": "1.1.15", "@radix-ui/react-navigation-menu": "1.2.13", "@radix-ui/react-one-time-password-field": "0.1.7", "@radix-ui/react-password-toggle-field": "0.1.2", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.5", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-toggle-group": "1.1.10", "@radix-ui/react-toolbar": "1.1.10", "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q=="],
|
||||
|
||||
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
|
||||
|
|
@ -1085,10 +1045,6 @@
|
|||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
|
||||
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||
|
|
@ -1109,18 +1065,12 @@
|
|||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.45.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.1", "@rollup/rollup-android-arm64": "4.45.1", "@rollup/rollup-darwin-arm64": "4.45.1", "@rollup/rollup-darwin-x64": "4.45.1", "@rollup/rollup-freebsd-arm64": "4.45.1", "@rollup/rollup-freebsd-x64": "4.45.1", "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", "@rollup/rollup-linux-arm-musleabihf": "4.45.1", "@rollup/rollup-linux-arm64-gnu": "4.45.1", "@rollup/rollup-linux-arm64-musl": "4.45.1", "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-gnu": "4.45.1", "@rollup/rollup-linux-riscv64-musl": "4.45.1", "@rollup/rollup-linux-s390x-gnu": "4.45.1", "@rollup/rollup-linux-x64-gnu": "4.45.1", "@rollup/rollup-linux-x64-musl": "4.45.1", "@rollup/rollup-win32-arm64-msvc": "4.45.1", "@rollup/rollup-win32-ia32-msvc": "4.45.1", "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw=="],
|
||||
|
||||
"rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
|
@ -1137,8 +1087,6 @@
|
|||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
|
@ -1159,16 +1107,8 @@
|
|||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="],
|
||||
|
|
@ -1177,19 +1117,15 @@
|
|||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
|
||||
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
|
|
@ -1205,14 +1141,10 @@
|
|||
|
||||
"tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.20.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="],
|
||||
|
|
@ -1283,14 +1215,12 @@
|
|||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
|
||||
|
||||
"zod": ["zod@4.0.5", "", {}, "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="],
|
||||
|
|
@ -1301,16 +1231,20 @@
|
|||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
|
@ -1323,30 +1257,10 @@
|
|||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
|
@ -1391,6 +1305,8 @@
|
|||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||
|
|
@ -1416,11 +1332,5 @@
|
|||
"opencut/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A=="],
|
||||
|
||||
"opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,5 +22,8 @@
|
|||
"dependencies": {
|
||||
"next": "^15.3.4",
|
||||
"wavesurfer.js": "^7.9.8"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue