diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index b7277d26..b6d67872 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -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 \ No newline at end of file +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/apps/web/.env.example b/apps/web/.env.example index b21f9a92..af84fd77 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,4 +16,8 @@ UPSTASH_REDIS_REST_TOKEN=example_token # Marble Blog MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file +NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + +# Freesound (generate at https://freesound.org/apiv2/apply/) +FREESOUND_CLIENT_ID=... +FREESOUND_API_KEY=... \ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json index 405a08cd..3289f237 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -4,7 +4,7 @@ "rsc": true, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, diff --git a/apps/web/package.json b/apps/web/package.json index 54f0c31f..df7ca525 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 1a69fd2a..79bcf135 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,7 +1,7 @@ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { - tailwindcss: {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts new file mode 100644 index 00000000..c89bc76c --- /dev/null +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -0,0 +1,265 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 } + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + // Use score sorting for search queries, downloads for top sounds + const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; + + const params = new URLSearchParams({ + query: query || "", + token: env.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); + + // Always apply sound effect filters (since we're primarily a sound effects search) + if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + + // Filter by license if commercial_only is true + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' + ); + } + + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" + ); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status } + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 } + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map((result) => ({ + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + })); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts deleted file mode 100644 index 9f00f221..00000000 --- a/apps/web/src/app/api/waitlist/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db, eq, waitlist } from "@opencut/db"; -import { checkBotId } from "botid/server"; -import { nanoid } from "nanoid"; -import { waitlistRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; -import { env } from "@/env"; -import { cookies } from "next/headers"; -import crypto from "crypto"; - -const waitlistSchema = z.object({ - email: z.string().email("Invalid email format").min(1, "Email is required"), -}); - -const CSRF_TOKEN_NAME = "waitlist-csrf"; -const TOKEN_EXPIRY = 60 * 60 * 1000; - -async function validateCSRFToken(request: NextRequest): Promise { - const clientToken = request.headers.get("x-csrf-token"); - if (!clientToken) return false; - - const cookieStore = await cookies(); - const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value; - if (!cookieValue) return false; - - const [token, timestamp, signature] = cookieValue.split(":"); - if (!token || !timestamp || !signature) return false; - - if (clientToken !== token) return false; - - const now = Date.now(); - const tokenTime = parseInt(timestamp); - if (now - tokenTime > TOKEN_EXPIRY) return false; - - const expectedSignature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); - - return signature === expectedSignature; -} - -export async function POST(request: NextRequest) { - const verification = await checkBotId(); - - if (verification.isBot) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; - const { success } = await waitlistRateLimit.limit(identifier); - - if (!success) { - return NextResponse.json( - { error: "Too many requests. Please try again later." }, - { status: 429 } - ); - } - const isValidToken = await validateCSRFToken(request); - if (!isValidToken) { - return NextResponse.json( - { error: "Invalid security token" }, - { status: 403 } - ); - } - - try { - const body = await request.json(); - const { email } = waitlistSchema.parse(body); - - const existingEmail = await db - .select() - .from(waitlist) - .where(eq(waitlist.email, email.toLowerCase())) - .limit(1); - - if (existingEmail.length > 0) { - return NextResponse.json( - { error: "Email already registered" }, - { status: 409 } - ); - } - - await db.insert(waitlist).values({ - id: nanoid(), - email: email.toLowerCase(), - }); - - return NextResponse.json( - { message: "Successfully joined waitlist!" }, - { status: 201 } - ); - } catch (error) { - if (error instanceof z.ZodError) { - const firstError = error.errors[0]; - return NextResponse.json({ error: firstError.message }, { status: 400 }); - } - - console.error("Waitlist signup error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts deleted file mode 100644 index fc2ff32c..00000000 --- a/apps/web/src/app/api/waitlist/token/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import crypto from "crypto"; -import { env } from "@/env"; - -const CSRF_TOKEN_NAME = "waitlist-csrf"; -const TOKEN_EXPIRY = 60 * 60 * 1000; -const allowedHosts = - env.NODE_ENV === "development" - ? ["localhost:3000", "127.0.0.1:3000"] - : ["opencut.app", "www.opencut.app"]; - -export async function GET(request: NextRequest) { - const referer = request.headers.get("referer"); - const host = request.headers.get("host"); - - if (referer) { - const refererUrl = new URL(referer); - - if ( - !allowedHosts.some( - (allowed) => - refererUrl.host === allowed || refererUrl.host.endsWith(allowed) - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else if (host) { - if ( - !allowedHosts.some( - (allowed) => host === allowed || host.endsWith(allowed) - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - if (!env.BETTER_AUTH_SECRET) { - throw new Error("BETTER_AUTH_SECRET must be configured"); - } - - const token = crypto.randomBytes(32).toString("hex"); - const timestamp = Date.now(); - const signature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); - - const cookieStore = await cookies(); - cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict", - maxAge: TOKEN_EXPIRY / 1000, - path: "/", - }); - - return NextResponse.json({ token }); -} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 0de1e0a8..d759050b 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -87,8 +87,8 @@ async function Page({ params }: PageProps) {
-
-
+
+
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index ca553a67..08da9fd7 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -28,8 +28,8 @@ export default async function BlogPage() {
-
-
+
+
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 29151bd8..3ad99023 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -77,8 +77,8 @@ export default async function ContributorsPage() {
-
-
+
+
@@ -138,8 +138,8 @@ export default async function ContributorsPage() { className="group block flex-1" >
-
- +
+
@@ -268,7 +268,7 @@ export default async function ContributorsPage() { animationDelay: `${index * 100}ms`, }} > - +
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx new file mode 100644 index 00000000..151f7f94 --- /dev/null +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; + +export default function EditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + useGlobalPrefetcher(); + + return
{children}
; +} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 26fdcf8a..cae8e779 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -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>(new Set()); + const isInitializingRef = useRef(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 ( @@ -85,7 +167,7 @@ export default function Editor() { {/* Main content area */} {/* Tools Panel */} @@ -117,7 +199,7 @@ export default function Editor() { minSize={15} maxSize={40} onResize={setPropertiesPanel} - className="min-w-0" + className="min-w-0 rounded-sm" > @@ -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" > diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2f7158cc..ca78ee26 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5c1d7520..c5a9bf45 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -14,8 +14,8 @@ export const metadata = baseMetaData; const protectedRoutes = [ { - path: "/api/waitlist", - method: "POST", + path: "/none", + method: "GET", }, ]; @@ -30,7 +30,7 @@ export default function RootLayout({ - + {children} diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index b7d700b5..4bb1e023 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -24,8 +24,8 @@ export default function PrivacyPage() {
-
-
+
+
@@ -47,7 +47,7 @@ export default function PrivacyPage() { have any questions.

- +

diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 289bf9c1..797cb544 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -142,7 +142,7 @@ export default function ProjectsPage() { href="/" className="flex items-center gap-1 hover:text-muted-foreground transition-colors" > - + Back
@@ -153,7 +153,7 @@ export default function ProjectsPage() { size="sm" onClick={handleCancelSelection} > - + Cancel {selectedProjects.size > 0 && ( @@ -162,7 +162,7 @@ export default function ProjectsPage() { size="sm" onClick={() => setIsBulkDeleteDialogOpen(true)} > - + Delete ({selectedProjects.size}) )} @@ -192,7 +192,7 @@ export default function ProjectsPage() { {isSelectionMode ? (
{selectedProjects.size > 0 && ( @@ -200,7 +200,7 @@ export default function ProjectsPage() { variant="destructive" onClick={() => setIsBulkDeleteDialogOpen(true)} > - + Delete Selected ({selectedProjects.size}) )} @@ -399,7 +399,7 @@ function ProjectCard({ > {isSelectionMode && (
-
+
@@ -426,7 +426,7 @@ function ProjectCard({ /> ) : (
-
)}
@@ -501,7 +501,7 @@ function ProjectCard({
- + Created {formatDate(project.createdAt)}
@@ -543,7 +543,7 @@ function ProjectCard({ function CreateButton({ onClick }: { onClick?: () => void }) { return ( ); diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index 5c003cf0..b26da5aa 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -122,8 +122,8 @@ export default function RoadmapPage() {
-
-
+
+
@@ -148,7 +148,7 @@ export default function RoadmapPage() { {roadmapItems.map((item, index) => (
- + {index + 1}.
@@ -156,13 +156,13 @@ export default function RoadmapPage() {

{item.title}

diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 7d1fda7f..ee202d8b 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -24,8 +24,8 @@ export default function TermsPage() {
-
-
+
+
@@ -47,7 +47,7 @@ export default function TermsPage() { editor.

- +

diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx index 7fe6df66..b05860a6 100644 --- a/apps/web/src/app/why-not-capcut/page.tsx +++ b/apps/web/src/app/why-not-capcut/page.tsx @@ -7,8 +7,8 @@ export default function WhyNotCapcut() {
-
-
+
+
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index a640dc4c..0457cb05 100644 --- a/apps/web/src/components/background-settings.tsx +++ b/apps/web/src/components/background-settings.tsx @@ -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() { - +

Background

@@ -100,7 +100,7 @@ function ColorView({ }) { return (
-
+
diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx index e2b32dda..e8f8703c 100644 --- a/apps/web/src/components/delete-project-dialog.tsx +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -49,7 +49,7 @@ export function DeleteProjectDialog({
+ + {activeProject?.name} + + + + + + + Projects + + + setIsRenameDialogOpen(true)} + > + + Rename project + + setIsDeleteDialogOpen(true)} + > + + Delete Project + + + + + + Discord + + + + + +
); const centerContent = (
- + + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + + / + {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -106,12 +150,20 @@ export function EditorHeader() { + ); @@ -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" /> ); } diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC = ({ 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 = ({ 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 = ({ } }); - 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 = ({ } }; - 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]); diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8..70eb69dd 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -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 = { media: , - audio: , + sounds: , text: , stickers: (
@@ -43,12 +45,14 @@ export function MediaPanel() { Adjustment view coming soon...
), + settings: , }; return ( -
+
-
{viewMap[activeTab]}
+ +
{viewMap[activeTab]}
); } diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index c661f51e..e1212030 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -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 { diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index bce84a46..fc4f677d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -69,21 +69,20 @@ export function TabBar() { />
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; return (
setActiveTab(tabKey)} key={tabKey} > - - {tab.label} +
); })} @@ -114,10 +113,10 @@ function ScrollButton({
); diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx deleted file mode 100644 index ba64d749..00000000 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function AudioView() { - const [search, setSearch] = useState(""); - return ( -
- setSearch(e.target.value)} - /> -
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index f218dc14..772ef4ab 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -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; +}) { + return ( + + {children} + + Export clips + onRemove(e, item.id)} + > + Delete + + + + ); +} export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); + const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef(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 ( -
- {item.name} -
- ); - } + const previewComponents = useMemo(() => { + const previews = new Map(); - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
+ filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; + + if (item.type === "image") { + preview = ( +
{item.name} -
-
- {item.duration && ( -
- {formatDuration(item.duration)} +
+ ); + } else if (item.type === "video") { + if (item.thumbnailUrl) { + preview = ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } else { + preview = ( +
+
+ ); + } + } else if (item.type === "audio") { + preview = ( +
+ + Audio + {item.duration && ( + + {formatDuration(item.duration)} + )}
); + } else { + preview = ( +
+ + Unknown +
+ ); } - return ( -
-
- ); - } - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } + previews.set(item.id, preview); + }); - return ( -
- - Unknown -
- ); - }; + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaItem) => previewComponents.get(item.id); return ( <> @@ -207,43 +290,152 @@ export function MediaView() { >
{/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> +
+
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+ + + + + + + + + { + if (sortBy === "name") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("name"); + setSortOrder("asc"); + } + }} + > + Name{" "} + {sortBy === "name" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "type") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("type"); + setSortOrder("asc"); + } + }} + > + Type{" "} + {sortBy === "type" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "duration") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("duration"); + setSortOrder("asc"); + } + }} + > + Duration{" "} + {sortBy === "duration" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "size") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("size"); + setSortOrder("asc"); + } + }} + > + File Size{" "} + {sortBy === "size" && + (sortOrder === "asc" ? "↑" : "↓")} + + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+
+
- -
+
+
{isDragOver || filteredMediaItems.length === 0 ? ( + ) : mediaViewMode === "grid" ? ( + ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - - - - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - - ))} -
+ )}
- +
); } + +function GridView({ + filteredMediaItems, + renderPreview, + handleRemove, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + rounded={false} + variant="card" + /> + + ))} +
+ ); +} + +function ListView({ + filteredMediaItems, + renderPreview, + handleRemove, + formatDuration, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; + formatDuration: (duration: number) => string; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + variant="compact" + /> + + ))} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx new file mode 100644 index 00000000..3bce1753 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -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 ; +} + +function ProjectSettingsTabs() { + return ( +
+ +
+ + Project info + Background + +
+ + + + + + + + + +
+
+ ); +} + +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 ( +
+ + Name + + {activeProject?.name || "Untitled project"} + + + + + Aspect ratio + + + + + + + Frame rate + + + + +
+ ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( +
+ {`Blur +
+ + {blur.label} + +
+
+ ) +); + +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) => ( +
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) => ( + handleBlurSelect(blur.value)} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] + ); + + return ( +
+ +
{blurPreviews}
+
+ + +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx new file mode 100644 index 00000000..ed0db8ab --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { useState, useMemo, useRef, useEffect } from "react"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + PlayIcon, + PauseIcon, + HeartIcon, + PlusIcon, + ListFilter, +} from "lucide-react"; +import { useSoundsStore } from "@/stores/sounds-store"; +import { useSoundSearch } from "@/hooks/use-sound-search"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export function SoundsView() { + return ( +
+ +
+ + Sound effects + Songs + Saved + +
+ + + + + + + + + + +
+
+ ); +} + +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(null); + const [audioElement, setAudioElement] = useState( + null + ); + + // Scroll position persistence + const scrollAreaRef = useRef(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) => { + 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 ( +
+
+ setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + + + + + + + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+
+
+ +
+ +
+ {isLoading && !searchQuery && ( +
+ Loading sounds... +
+ )} + {isSearching && searchQuery && ( +
Searching...
+ )} + {displayedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => toggleSavedSound(sound)} + /> + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( +
+ {searchQuery ? "No sounds found" : "No sounds available"} +
+ )} + {isLoadingMore && ( +
+ Loading more sounds... +
+ )} +
+
+
+
+ ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + clearSavedSounds, + } = useSoundsStore(); + + // Audio playback state + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + 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 ( +
+
+ Loading saved sounds... +
+
+ ); + } + + if (savedSoundsError) { + return ( +
+
+ Error: {savedSoundsError} +
+
+ ); + } + + if (savedSounds.length === 0) { + return ( +
+ +
+

No saved sounds

+

+ Click the heart icon on any sound to save it here +

+
+
+ ); + } + + return ( +
+
+

+ {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} +

+ + + + + + + Clear all saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {savedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => + toggleSavedSound(convertToSoundEffect(sound)) + } + /> + ))} +
+
+
+
+ ); +} + +function SongsView() { + return
Songs
; +} + +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 ( +
+
+
+ {isPlaying ? ( + + ) : ( + + )} +
+ +
+

{sound.name}

+ + {sound.username} + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 0686190e..ca8e88c9 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -32,7 +32,7 @@ export function TextView() { +
Default text
} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 4cc35bb5..b9c03382 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -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 (
🎬
-

{element.name}

+

{element.name}

); @@ -475,10 +467,10 @@ export function PreviewPanel() { return ( <> -
+
{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 (
-
+
/ @@ -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" > @@ -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 ? ( @@ -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" > @@ -681,7 +673,7 @@ function FullscreenToolbar({
@@ -705,11 +697,11 @@ function FullscreenToolbar({
); @@ -743,14 +735,14 @@ function FullscreenPreview({ getTotalDuration: () => number; }) { return ( -
+
-
+
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 (
-
-

- - / - - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - -

-
- -
- - - - - - - - Original - - - {canvasPresets.map((preset) => ( - handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - - ))} - - +
+
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 720c4a26..6c79327e 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -1,95 +1,22 @@ "use client"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { useMediaStore } from "@/stores/media-store"; -import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; -import { Label } from "../../ui/label"; import { ScrollArea } from "../../ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../ui/select"; import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; import { TextProperties } from "./text-properties"; +import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); const { selectedElements, tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - if (!isNaN(fps) && fps > 0) { - updateProjectFps(fps); - } - }; - - const emptyView = ( -
- {/* Media Properties */} -
- - - Name: - - - {activeProject?.name || ""} - - - - - Aspect ratio: - - - {getDisplayName()} - - - - - Resolution: - - - {`${canvasSize.width} × ${canvasSize.height}`} - - -
- - -
-
-
- ); - return ( - - {selectedElements.length > 0 - ? selectedElements.map(({ trackId, elementId }) => { + <> + {selectedElements.length > 0 ? ( + + {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} - + })} + + ) : ( + + )} + + ); +} + +function EmptyView() { + return ( +
+ +
+

It’s empty here

+

+ Click an element on the timeline to edit its properties +

+
+
); } diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index 804ffb39..dc6b6d74 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -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 ; + return ( + + ); } export function PropertyItemValue({ @@ -43,5 +49,36 @@ export function PropertyItemValue({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return
{children}
; +} + +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 ( + +
setIsExpanded(!isExpanded)} + > + + {title} + + +
+ {isExpanded && {children}} +
+ ); } diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index f8380ff4..a06624eb 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -91,7 +91,7 @@ export function TextProperties({