summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/Dockerfile5
-rw-r--r--apps/web/drizzle.config.ts46
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts560
-rw-r--r--apps/web/src/app/layout.tsx2
-rw-r--r--apps/web/src/app/projects/page.tsx2030
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx738
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx606
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx460
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx1530
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx2
-rw-r--r--apps/web/src/components/icons/brand.tsx (renamed from packages/ui/src/icons/brand.tsx)0
-rw-r--r--apps/web/src/components/icons/index.tsx (renamed from packages/ui/src/icons/index.tsx)0
-rw-r--r--apps/web/src/components/icons/ui.tsx (renamed from packages/ui/src/icons/ui.tsx)0
-rw-r--r--apps/web/src/constants/site-constants.ts138
-rw-r--r--apps/web/src/lib/auth/client.ts12
-rw-r--r--apps/web/src/lib/auth/server.ts86
-rw-r--r--apps/web/src/lib/db/index.ts2
-rw-r--r--apps/web/src/lib/env/web.ts (renamed from packages/env/src/web.ts)0
-rw-r--r--apps/web/src/lib/rate-limit.ts42
-rw-r--r--bun.lock48
-rw-r--r--packages/env/package.json21
-rw-r--r--packages/ui/package.json19
-rw-r--r--packages/ui/tsconfig.json20
25 files changed, 3136 insertions, 3235 deletions
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index b569d629..34b63e3c 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
-COPY packages/env/package.json packages/env/package.json
-COPY packages/ui/package.json packages/ui/package.json
-
RUN bun install
COPY apps/web/ apps/web/
-COPY packages/env/ packages/env/
-COPY packages/ui/ packages/ui/
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts
index e3fd2d19..494fd35d 100644
--- a/apps/web/drizzle.config.ts
+++ b/apps/web/drizzle.config.ts
@@ -1,23 +1,23 @@
-import type { Config } from "drizzle-kit";
-import * as dotenv from "dotenv";
-import { webEnv } from "@opencut/env/web";
-
-// Load the right env file based on environment
-if (webEnv.NODE_ENV === "production") {
- dotenv.config({ path: ".env.production" });
-} else {
- dotenv.config({ path: ".env.local" });
-}
-
-export default {
- schema: "./src/schema.ts",
- dialect: "postgresql",
- migrations: {
- table: "drizzle_migrations",
- },
- dbCredentials: {
- url: webEnv.DATABASE_URL,
- },
- out: "./migrations",
- strict: webEnv.NODE_ENV === "production",
-} satisfies Config;
+import type { Config } from "drizzle-kit";
+import * as dotenv from "dotenv";
+import { webEnv } from "@/lib/env/web";
+
+// Load the right env file based on environment
+if (webEnv.NODE_ENV === "production") {
+ dotenv.config({ path: ".env.production" });
+} else {
+ dotenv.config({ path: ".env.local" });
+}
+
+export default {
+ schema: "./src/schema.ts",
+ dialect: "postgresql",
+ migrations: {
+ table: "drizzle_migrations",
+ },
+ dbCredentials: {
+ url: webEnv.DATABASE_URL,
+ },
+ out: "./migrations",
+ strict: webEnv.NODE_ENV === "production",
+} satisfies Config;
diff --git a/apps/web/package.json b/apps/web/package.json
index 91114a87..371b2570 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,8 +22,6 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
- "@opencut/env": "workspace:*",
- "@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
index 3070d152..c4d065a9 100644
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -1,280 +1,280 @@
-import { webEnv } from "@opencut/env/web";
-import { type NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { checkRateLimit } 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(),
-});
-
-function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
- if (!query) return `${sort}_desc`;
- return sort === "score" ? "score" : `${sort}_desc`;
-}
-
-function applyEffectsFilters({
- params,
- min_rating,
- commercial_only,
-}: {
- params: URLSearchParams;
- min_rating: number;
- commercial_only: boolean;
-}) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
-
- 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",
- );
-}
-
-function transformFreesoundResult(
- result: z.infer<typeof freesoundResultSchema>,
-) {
- return {
- 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,
- };
-}
-
-export async function GET(request: NextRequest) {
- try {
- const { limited } = await checkRateLimit({ request });
- if (limited) {
- 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/";
-
- const sortParam = buildSortParameter({ query, sort });
-
- const params = new URLSearchParams({
- query: query || "",
- token: webEnv.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",
- });
-
- const isEffectsSearch = type === "effects" || !type;
- if (isEffectsSearch) {
- applyEffectsFilters({ params, min_rating, commercial_only });
- }
-
- 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(transformFreesoundResult);
-
- 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 },
- );
- }
-}
+import { webEnv } from "@/lib/env/web";
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { checkRateLimit } 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(),
+});
+
+function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
+ if (!query) return `${sort}_desc`;
+ return sort === "score" ? "score" : `${sort}_desc`;
+}
+
+function applyEffectsFilters({
+ params,
+ min_rating,
+ commercial_only,
+}: {
+ params: URLSearchParams;
+ min_rating: number;
+ commercial_only: boolean;
+}) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
+
+ 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",
+ );
+}
+
+function transformFreesoundResult(
+ result: z.infer<typeof freesoundResultSchema>,
+) {
+ return {
+ 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,
+ };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ 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/";
+
+ const sortParam = buildSortParameter({ query, sort });
+
+ const params = new URLSearchParams({
+ query: query || "",
+ token: webEnv.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",
+ });
+
+ const isEffectsSearch = type === "effects" || !type;
+ if (isEffectsSearch) {
+ applyEffectsFilters({ params, min_rating, commercial_only });
+ }
+
+ 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(transformFreesoundResult);
+
+ 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/layout.tsx b/apps/web/src/app/layout.tsx
index b4ef36c7..b65e028d 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
-import { webEnv } from "@opencut/env/web";
+import { webEnv } from "@/lib/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 8c5d9d04..74d94b3a 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,1015 +1,1015 @@
-"use client";
-
-import Image from "next/image";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import type { KeyboardEvent, MouseEvent } from "react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import type { EditorCore } from "@/core";
-import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
-import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useEditor } from "@/hooks/use-editor";
-import { useProjectsStore } from "./store";
-import type {
- TProjectMetadata,
- TProjectSortKey,
- TProjectSortOption,
-} from "@/lib/project/types";
-import { formatTimeCode } from "@/lib/time";
-import { formatDate } from "@/utils/date";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb";
-import {
- Calendar04Icon,
- GridViewIcon,
- LeftToRightListDashIcon,
- PlusSignIcon,
- Search01Icon,
- Video01Icon,
- MoreHorizontalIcon,
- Delete02Icon,
- Copy01Icon,
- Edit03Icon,
- ArrowDown02Icon,
- InformationCircleIcon,
-} from "@hugeicons/core-free-icons";
-import { OcVideoIcon } from "@opencut/ui/icons";
-import { Label } from "@/components/ui/label";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
-import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
-import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
-import { cn } from "@/utils/ui";
-
-const formatProjectDuration = ({
- duration,
-}: {
- duration: number | undefined;
-}): string | null => {
- if (duration === undefined) {
- return null;
- }
-
- const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
- return formatTimeCode({ timeInSeconds: duration, format });
-};
-
-const VIEW_MODE_OPTIONS = [
- { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
- { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
-];
-
-export default function ProjectsPage() {
- const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
- const editor = useEditor();
- const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
-
- const isLoading = useEditor((e) => e.project.getIsLoading());
- const isInitialized = useEditor((e) => e.project.getIsInitialized());
- const projectsToDisplay = useEditor((e) =>
- e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
- );
-
- useEffect(() => {
- if (!editor.project.getIsInitialized()) {
- editor.project.loadAllProjects();
- }
- }, [editor.project]);
-
- return (
- <div className="bg-background min-h-screen">
- <MigrationDialog />
- <StoragePersistenceDialog />
- <ProjectsHeader />
- <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
- <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
- {isLoading || !isInitialized ? (
- <ProjectsSkeleton />
- ) : projectsToDisplay.length === 0 ? (
- <EmptyState />
- ) : (
- <div
- className={
- viewMode === "grid"
- ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
- : "flex flex-col"
- }
- >
- {projectsToDisplay.map((project) => (
- <ProjectItem
- key={project.id}
- project={project}
- allProjectIds={projectsToDisplay.map((p) => p.id)}
- />
- ))}
- </div>
- )}
- </main>
- </div>
- );
-}
-
-function ProjectsHeader() {
- const { viewMode, isHydrated, setViewMode } = useProjectsStore();
-
- return (
- <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
- <div className="flex items-center justify-between h-16 pt-2">
- <div className="flex items-center gap-5">
- <Breadcrumb>
- <BreadcrumbList>
- <BreadcrumbItem>
- <BreadcrumbLink asChild>
- <Link href="/" className="text-sm sm:text-base">
- Home
- </Link>
- </BreadcrumbLink>
- </BreadcrumbItem>
- <BreadcrumbSeparator />
- <BreadcrumbItem>
- <BreadcrumbPage className="text-sm sm:text-base font-medium">
- All projects
- </BreadcrumbPage>
- </BreadcrumbItem>
- </BreadcrumbList>
- </Breadcrumb>
-
- <div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
- <Button
- key={mode}
- variant="ghost"
- size="icon"
- className={cn(
- "rounded-sm hover:bg-background",
- isHydrated && viewMode === mode && "!bg-accent",
- )}
- onClick={() => setViewMode({ viewMode: mode })}
- aria-label={label}
- aria-pressed={isHydrated && viewMode === mode}
- >
- <HugeiconsIcon icon={icon} className="size-4" />
- </Button>
- ))}
- </div>
- </div>
-
- <div className="flex items-center gap-3 md:gap-4">
- <SearchBar className="hidden md:block" />
- <NewProjectButton />
- </div>
- </div>
- <SearchBar className="block md:hidden mb-4" />
- </header>
- );
-}
-
-const SORT_LABELS: Record<TProjectSortKey, string> = {
- createdAt: "Created",
- updatedAt: "Modified",
- name: "Name",
- duration: "Duration",
-};
-
-function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
- const {
- selectedProjectIds,
- sortKey,
- sortOrder,
- setSortOrder,
- setSelectedProjects,
- clearSelectedProjects,
- viewMode,
- setViewMode,
- } = useProjectsStore();
-
- const selectedProjectCount = selectedProjectIds.length;
- const isAllSelected =
- projectIds.length > 0 && selectedProjectCount === projectIds.length;
- const hasSomeSelected =
- selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
-
- const handleSelectAll = ({ checked }: { checked: boolean }) => {
- if (checked) {
- setSelectedProjects({ projectIds });
- return;
- }
- clearSelectedProjects();
- };
-
- return (
- <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
- <div className="flex items-center gap-2">
- <Label
- className="flex items-center gap-3 cursor-pointer px-2"
- htmlFor="select-all-projects"
- >
- <Checkbox
- className="size-5"
- id="select-all-projects"
- checked={
- isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
- }
- onCheckedChange={(checked) =>
- handleSelectAll({ checked: checked === true })
- }
- />
- <span className="text-muted-foreground hidden md:block">
- Select all
- </span>
- </Label>
-
- <div className="h-4 w-px bg-border/50" />
-
- <SortDropdown>
- <Button variant="text" className="text-muted-foreground pl-2">
- {SORT_LABELS[sortKey]}
- </Button>
- </SortDropdown>
- <Button
- variant="text"
- className="text-muted-foreground"
- onClick={() =>
- setSortOrder({
- sortOrder: sortOrder === "asc" ? "desc" : "asc",
- })
- }
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- setSortOrder({
- sortOrder: sortOrder === "asc" ? "desc" : "asc",
- });
- }
- }}
- aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
- >
- <HugeiconsIcon
- icon={ArrowDown02Icon}
- className={sortOrder === "asc" ? "rotate-180" : ""}
- />
- </Button>
-
- <div className="h-4 w-px bg-border/50 block md:hidden" />
-
- <div className="flex md:hidden items-center gap-4">
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
- <Button
- key={mode}
- variant="text"
- onClick={() => setViewMode({ viewMode: mode })}
- aria-label={label}
- >
- <HugeiconsIcon
- icon={icon}
- className={cn(
- viewMode === mode ? "text-primary" : "text-muted-foreground",
- )}
- />
- </Button>
- ))}
- </div>
- </div>
- {selectedProjectCount > 0 ? <ProjectActions /> : null}
- </div>
- );
-}
-
-function SearchBar({
- className,
- collapsed,
-}: {
- className?: string;
- collapsed?: boolean;
-}) {
- const { searchQuery, setSearchQuery } = useProjectsStore();
-
- return (
- <>
- {collapsed ? (
- <div className="block md:hidden">
- <Button
- size="icon"
- variant="outline"
- className="size-10.5 rounded-full"
- >
- <HugeiconsIcon icon={Search01Icon} />
- </Button>
- </div>
- ) : (
- <div className={cn("relative", className)}>
- <HugeiconsIcon
- icon={Search01Icon}
- className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
- aria-hidden="true"
- />
- <Input
- placeholder="Search..."
- value={searchQuery}
- onChange={(event) => setSearchQuery({ query: event.target.value })}
- size="lg"
- className="pl-9"
- />
- </div>
- )}
- </>
- );
-}
-
-const PROJECT_ACTIONS = [
- {
- id: "duplicate",
- label: "Duplicate",
- icon: Copy01Icon,
- variant: "outline" as const,
- },
- {
- id: "delete",
- label: "Delete",
- icon: Delete02Icon,
- variant: "destructive-foreground" as const,
- },
-] as const;
-
-async function deleteProjects({
- editor,
- ids,
-}: {
- editor: EditorCore;
- ids: string[];
-}) {
- await editor.project.deleteProjects({ ids });
-}
-
-async function duplicateProjects({
- editor,
- ids,
-}: {
- editor: EditorCore;
- ids: string[];
-}) {
- await editor.project.duplicateProjects({ ids });
-}
-
-async function renameProject({
- editor,
- id,
- name,
-}: {
- editor: EditorCore;
- id: string;
- name: string;
-}) {
- await editor.project.renameProject({ id, name });
-}
-
-function ProjectActions() {
- const editor = useEditor();
- const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
-
- const savedProjects = editor.project.getSavedProjects();
- const selectedProjectNames = savedProjects
- .filter((project) => selectedProjectIds.includes(project.id))
- .map((project) => project.name);
-
- const handleDuplicate = async () => {
- await duplicateProjects({ editor, ids: selectedProjectIds });
- clearSelectedProjects();
- };
-
- const handleDeleteClick = () => {
- setIsDeleteDialogOpen(true);
- };
-
- const handleDeleteConfirm = async () => {
- await deleteProjects({ editor, ids: selectedProjectIds });
- clearSelectedProjects();
- setIsDeleteDialogOpen(false);
- };
-
- const actionHandlers: Record<string, () => void> = {
- duplicate: handleDuplicate,
- delete: handleDeleteClick,
- };
-
- return (
- <>
- <div className="flex items-center gap-2.5 px-3">
- <div className="hidden sm:flex items-center gap-2.5">
- {PROJECT_ACTIONS.map((action) => (
- <Button
- key={action.id}
- size="icon"
- variant={action.variant}
- className="size-9"
- onClick={actionHandlers[action.id]}
- >
- <HugeiconsIcon icon={action.icon} />
- </Button>
- ))}
- </div>
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild className="sm:hidden">
- <Button size="icon" variant="outline" className="size-9">
- <HugeiconsIcon icon={MoreHorizontalIcon} />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {PROJECT_ACTIONS.map((action) => (
- <DropdownMenuItem
- key={action.id}
- variant={action.id === "delete" ? "destructive" : undefined}
- onClick={actionHandlers[action.id]}
- >
- <HugeiconsIcon icon={action.icon} />
- {action.label}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- projectNames={selectedProjectNames}
- onConfirm={handleDeleteConfirm}
- />
- </>
- );
-}
-
-function SortDropdown({ children }: { children: React.ReactNode }) {
- const { sortKey, setSortKey } = useProjectsStore();
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
- <DropdownMenuContent className="w-48" align="center">
- <DropdownMenuCheckboxItem
- checked={sortKey === "createdAt"}
- onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
- >
- Created
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "updatedAt"}
- onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
- >
- Modified
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "name"}
- onCheckedChange={() => setSortKey({ sortKey: "name" })}
- >
- Name
- </DropdownMenuCheckboxItem>
- <DropdownMenuCheckboxItem
- checked={sortKey === "duration"}
- onCheckedChange={() => setSortKey({ sortKey: "duration" })}
- >
- Duration
- </DropdownMenuCheckboxItem>
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
-
-function NewProjectButton() {
- const editor = useEditor();
- const router = useRouter();
-
- const handleCreateProject = async () => {
- const projectId = await editor.project.createNewProject({
- name: "New project",
- });
- router.push(`/editor/${projectId}`);
- };
-
- return (
- <Button
- size="lg"
- className="flex px-5 md:px-6"
- onClick={handleCreateProject}
- >
- <span className="text-sm font-medium hidden md:block">New project</span>
- <span className="text-sm font-medium block md:hidden">New</span>
- </Button>
- );
-}
-
-function ProjectItem({
- project,
- allProjectIds,
-}: {
- project: TProjectMetadata;
- allProjectIds: string[];
-}) {
- const {
- selectedProjectIds,
- viewMode,
- setProjectSelected,
- selectProjectRange,
- } = useProjectsStore();
- const selectedProjectIdSet = new Set(selectedProjectIds);
- const isSelected = selectedProjectIdSet.has(project.id);
- const selectedProjectCount = selectedProjectIds.length;
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
- const editor = useEditor();
- const durationLabel = formatProjectDuration({ duration: project.duration });
- const isMultiSelect = selectedProjectCount > 1;
- const isGridView = viewMode === "grid";
-
- const handleRename = () => setIsRenameDialogOpen(true);
- const handleDuplicate = async () => {
- await duplicateProjects({ editor, ids: [project.id] });
- };
- const handleDeleteClick = () => setIsDeleteDialogOpen(true);
- const handleInfoClick = () => setIsInfoDialogOpen(true);
- const handleDeleteConfirm = async () => {
- await deleteProjects({ editor, ids: [project.id] });
- setIsDeleteDialogOpen(false);
- };
-
- const handleCheckboxChange = ({
- checked,
- shiftKey,
- }: {
- checked: boolean;
- shiftKey: boolean;
- }) => {
- if (shiftKey && checked) {
- selectProjectRange({ projectId: project.id, allProjectIds });
- return;
- }
- setProjectSelected({ projectId: project.id, isSelected: checked });
- };
-
- const gridContent = (
- <Card className="bg-background overflow-hidden border-none p-0">
- <div className="bg-muted relative aspect-video">
- <div className="absolute inset-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="flex size-full items-center justify-center">
- <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
- </div>
- )}
- </div>
-
- {durationLabel && (
- <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
- {durationLabel}
- </div>
- )}
- </div>
-
- <CardContent className="flex flex-col gap-2 px-0 pt-4">
- <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
- {project.name}
- </h3>
- <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
- <HugeiconsIcon icon={Calendar04Icon} className="size-4" />
- <span>Created {formatDate({ date: project.createdAt })}</span>
- </div>
- </CardContent>
- </Card>
- );
-
- const listRowContent = (
- <div className="flex items-center gap-3 flex-1 min-w-0">
- <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="flex size-full items-center justify-center">
- <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
- </div>
- )}
- </div>
-
- <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
- {project.name}
- </h3>
-
- <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
- {durationLabel ?? "—"}
- </span>
-
- <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
- {formatDate({ date: project.createdAt })}
- </span>
- </div>
- );
-
- const listContent = (
- <div
- className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
- isSelected ? "bg-primary/5" : ""
- }`}
- >
- <Checkbox
- checked={isSelected}
- onMouseDown={(event) => event.preventDefault()}
- onClick={(event) => {
- handleCheckboxChange({
- checked: !isSelected,
- shiftKey: event.shiftKey,
- });
- }}
- onCheckedChange={() => {}}
- className="size-5 shrink-0"
- />
-
- <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
- {listRowContent}
- </Link>
-
- {!isMultiSelect && (
- <ProjectMenu
- isOpen={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- variant="list"
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- )}
- </div>
- );
-
- return (
- <>
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div className="group relative">
- {isGridView ? (
- <>
- <Link href={`/editor/${project.id}`} className="block">
- {gridContent}
- </Link>
-
- <Checkbox
- checked={isSelected}
- onMouseDown={(event) => event.preventDefault()}
- onClick={(event) => {
- handleCheckboxChange({
- checked: !isSelected,
- shiftKey: event.shiftKey,
- });
- }}
- onCheckedChange={() => {}}
- className={`absolute z-10 size-5 top-3 left-3 ${
- isSelected || isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- />
-
- {!isMultiSelect && (
- <ProjectMenu
- isOpen={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- )}
- </>
- ) : (
- listContent
- )}
- </div>
- </ContextMenuTrigger>
- <ProjectContextMenuContent
- onRenameClick={handleRename}
- onDuplicateClick={handleDuplicate}
- onDeleteClick={handleDeleteClick}
- onInfoClick={handleInfoClick}
- />
- </ContextMenu>
-
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- projectName={project.name}
- onConfirm={async (newName) => {
- await renameProject({ editor, id: project.id, name: newName });
- setIsRenameDialogOpen(false);
- }}
- />
-
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- projectNames={[project.name]}
- onConfirm={handleDeleteConfirm}
- />
-
- <ProjectInfoDialog
- isOpen={isInfoDialogOpen}
- onOpenChange={setIsInfoDialogOpen}
- project={project}
- />
- </>
- );
-}
-
-function ProjectContextMenuContent({
- onRenameClick,
- onDuplicateClick,
- onDeleteClick,
- onInfoClick,
-}: {
- onRenameClick: () => void;
- onDuplicateClick: () => void;
- onDeleteClick: () => void;
- onInfoClick: () => void;
-}) {
- return (
- <ContextMenuContent>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Edit03Icon} />}
- onClick={onRenameClick}
- >
- Rename
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- onClick={onDuplicateClick}
- >
- Duplicate
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={InformationCircleIcon} />}
- onClick={onInfoClick}
- >
- Info
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- variant="destructive"
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- onClick={onDeleteClick}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- );
-}
-
-function ProjectMenu({
- isOpen,
- onOpenChange,
- variant = "grid",
- onRenameClick,
- onDuplicateClick,
- onDeleteClick,
- onInfoClick,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- variant?: "grid" | "list";
- onRenameClick: () => void;
- onDuplicateClick: () => void;
- onDeleteClick: () => void;
- onInfoClick: () => void;
-}) {
- const handleMenuClick = ({
- event,
- }: {
- event: MouseEvent<HTMLButtonElement>;
- }) => {
- event.preventDefault();
- event.stopPropagation();
- };
-
- const handleMenuKeyDown = ({
- event,
- }: {
- event: KeyboardEvent<HTMLButtonElement>;
- }) => {
- if (event.key !== "Enter" && event.key !== " ") {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- };
-
- const handleRename = () => {
- onRenameClick();
- onOpenChange(false);
- };
-
- const handleDuplicate = () => {
- onDuplicateClick();
- onOpenChange(false);
- };
-
- const handleDeleteClick = () => {
- onDeleteClick();
- onOpenChange(false);
- };
-
- const handleInfoClick = () => {
- onInfoClick();
- onOpenChange(false);
- };
-
- const isGrid = variant === "grid";
-
- return (
- <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
- <DropdownMenuTrigger asChild>
- <Button
- variant="background"
- className={
- isGrid
- ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
- : "!bg-transparent !shadow-none"
- }
- size="icon"
- aria-label="Project menu"
- onClick={(event) =>
- handleMenuClick({
- event: event as unknown as MouseEvent<HTMLButtonElement>,
- })
- }
- onMouseDown={(event) => event.stopPropagation()}
- onKeyDown={(event) =>
- handleMenuKeyDown({
- event: event as unknown as KeyboardEvent<HTMLButtonElement>,
- })
- }
- >
- <HugeiconsIcon
- icon={MoreHorizontalIcon}
- className="text-foreground"
- aria-hidden="true"
- />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-48" align="end">
- <DropdownMenuItem onClick={handleRename}>
- <HugeiconsIcon icon={Edit03Icon} />
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleDuplicate}>
- <HugeiconsIcon icon={Copy01Icon} />
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleInfoClick}>
- <HugeiconsIcon icon={InformationCircleIcon} />
- Info
- </DropdownMenuItem>
- <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
- <HugeiconsIcon icon={Delete02Icon} />
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
-
-function ProjectsSkeleton() {
- const skeletonIds = Array.from(
- { length: 24 },
- (_, index) => `skeleton-${index}`,
- );
-
- return (
- <div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
- {skeletonIds.map((skeletonId) => (
- <Card
- key={skeletonId}
- className="bg-background overflow-hidden border-none p-0"
- >
- <div className="bg-muted relative aspect-video">
- <div className="absolute inset-0">
- <Skeleton className="bg-muted/50 size-full" />
- </div>
- </div>
- <CardContent className="flex flex-col gap-2 px-0 pt-4">
- <Skeleton className="bg-muted/50 h-4 w-3/4" />
- <div className="text-muted-foreground flex items-center gap-1.5">
- <Skeleton className="bg-muted/50 size-4" />
- <Skeleton className="bg-muted/50 h-4 w-24" />
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- );
-}
-
-function EmptyState() {
- const { searchQuery, setSearchQuery } = useProjectsStore();
- const router = useRouter();
- const editor = useEditor();
- const savedProjects = editor.project.getSavedProjects();
-
- const handleCreateProject = async () => {
- try {
- const projectId = await editor.project.createNewProject({
- name: "New project",
- });
- router.push(`/editor/${projectId}`);
- } catch (error) {
- toast.error("Failed to create project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- }
- };
-
- if (savedProjects.length > 0) {
- return (
- <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
- <div className="flex flex-col items-center gap-8">
- <HugeiconsIcon
- icon={Search01Icon}
- className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
- />
- <div className="flex flex-col items-center gap-3">
- <h3 className="text-lg font-medium">No results found</h3>
- <p className="text-muted-foreground max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- </div>
- </div>
- <Button
- onClick={() => setSearchQuery({ query: "" })}
- variant="outline"
- size="lg"
- >
- Clear search
- </Button>
- </div>
- );
- }
-
- return (
- <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
- <div className="flex flex-col items-center gap-2">
- <div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
- <HugeiconsIcon
- icon={Video01Icon}
- className="text-muted-foreground size-8"
- />
- </div>
- <h3 className="text-lg font-medium">No projects yet</h3>
- <p className="text-muted-foreground max-w-md">
- Start creating your first project. Import media, edit, and export your
- videos. All privately.
- </p>
- </div>
- <Button size="lg" className="gap-2" onClick={handleCreateProject}>
- <HugeiconsIcon icon={PlusSignIcon} />
- Create your first project
- </Button>
- </div>
- );
-}
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import type { EditorCore } from "@/core";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useEditor } from "@/hooks/use-editor";
+import { useProjectsStore } from "./store";
+import type {
+ TProjectMetadata,
+ TProjectSortKey,
+ TProjectSortOption,
+} from "@/lib/project/types";
+import { formatTimeCode } from "@/lib/time";
+import { formatDate } from "@/utils/date";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import {
+ Calendar04Icon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ PlusSignIcon,
+ Search01Icon,
+ Video01Icon,
+ MoreHorizontalIcon,
+ Delete02Icon,
+ Copy01Icon,
+ Edit03Icon,
+ ArrowDown02Icon,
+ InformationCircleIcon,
+} from "@hugeicons/core-free-icons";
+import { OcVideoIcon } from "@/components/icons";
+import { Label } from "@/components/ui/label";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
+import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
+import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
+import { cn } from "@/utils/ui";
+
+const formatProjectDuration = ({
+ duration,
+}: {
+ duration: number | undefined;
+}): string | null => {
+ if (duration === undefined) {
+ return null;
+ }
+
+ const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
+ return formatTimeCode({ timeInSeconds: duration, format });
+};
+
+const VIEW_MODE_OPTIONS = [
+ { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
+ { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
+];
+
+export default function ProjectsPage() {
+ const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+
+ const isLoading = useEditor((e) => e.project.getIsLoading());
+ const isInitialized = useEditor((e) => e.project.getIsInitialized());
+ const projectsToDisplay = useEditor((e) =>
+ e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
+ );
+
+ useEffect(() => {
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
+ }
+ }, [editor.project]);
+
+ return (
+ <div className="bg-background min-h-screen">
+ <MigrationDialog />
+ <StoragePersistenceDialog />
+ <ProjectsHeader />
+ <ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
+ <main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
+ {isLoading || !isInitialized ? (
+ <ProjectsSkeleton />
+ ) : projectsToDisplay.length === 0 ? (
+ <EmptyState />
+ ) : (
+ <div
+ className={
+ viewMode === "grid"
+ ? "xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4 px-4"
+ : "flex flex-col"
+ }
+ >
+ {projectsToDisplay.map((project) => (
+ <ProjectItem
+ key={project.id}
+ project={project}
+ allProjectIds={projectsToDisplay.map((p) => p.id)}
+ />
+ ))}
+ </div>
+ )}
+ </main>
+ </div>
+ );
+}
+
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
+
+ return (
+ <header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
+ <div className="flex items-center justify-between h-16 pt-2">
+ <div className="flex items-center gap-5">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link href="/" className="text-sm sm:text-base">
+ Home
+ </Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage className="text-sm sm:text-base font-medium">
+ All projects
+ </BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
+ <Button
+ key={mode}
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "rounded-sm hover:bg-background",
+ isHydrated && viewMode === mode && "!bg-accent",
+ )}
+ onClick={() => setViewMode({ viewMode: mode })}
+ aria-label={label}
+ aria-pressed={isHydrated && viewMode === mode}
+ >
+ <HugeiconsIcon icon={icon} className="size-4" />
+ </Button>
+ ))}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-3 md:gap-4">
+ <SearchBar className="hidden md:block" />
+ <NewProjectButton />
+ </div>
+ </div>
+ <SearchBar className="block md:hidden mb-4" />
+ </header>
+ );
+}
+
+const SORT_LABELS: Record<TProjectSortKey, string> = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
+
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
+
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
+
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
+
+ return (
+ <div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
+ <div className="flex items-center gap-2">
+ <Label
+ className="flex items-center gap-3 cursor-pointer px-2"
+ htmlFor="select-all-projects"
+ >
+ <Checkbox
+ className="size-5"
+ id="select-all-projects"
+ checked={
+ isAllSelected ? true : hasSomeSelected ? "indeterminate" : false
+ }
+ onCheckedChange={(checked) =>
+ handleSelectAll({ checked: checked === true })
+ }
+ />
+ <span className="text-muted-foreground hidden md:block">
+ Select all
+ </span>
+ </Label>
+
+ <div className="h-4 w-px bg-border/50" />
+
+ <SortDropdown>
+ <Button variant="text" className="text-muted-foreground pl-2">
+ {SORT_LABELS[sortKey]}
+ </Button>
+ </SortDropdown>
+ <Button
+ variant="text"
+ className="text-muted-foreground"
+ onClick={() =>
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ })
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ });
+ }
+ }}
+ aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown02Icon}
+ className={sortOrder === "asc" ? "rotate-180" : ""}
+ />
+ </Button>
+
+ <div className="h-4 w-px bg-border/50 block md:hidden" />
+
+ <div className="flex md:hidden items-center gap-4">
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
+ <Button
+ key={mode}
+ variant="text"
+ onClick={() => setViewMode({ viewMode: mode })}
+ aria-label={label}
+ >
+ <HugeiconsIcon
+ icon={icon}
+ className={cn(
+ viewMode === mode ? "text-primary" : "text-muted-foreground",
+ )}
+ />
+ </Button>
+ ))}
+ </div>
+ </div>
+ {selectedProjectCount > 0 ? <ProjectActions /> : null}
+ </div>
+ );
+}
+
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+ <div className="block md:hidden">
+ <Button
+ size="icon"
+ variant="outline"
+ className="size-10.5 rounded-full"
+ >
+ <HugeiconsIcon icon={Search01Icon} />
+ </Button>
+ </div>
+ ) : (
+ <div className={cn("relative", className)}>
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
+ aria-hidden="true"
+ />
+ <Input
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(event) => setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+ </div>
+ )}
+ </>
+ );
+}
+
+const PROJECT_ACTIONS = [
+ {
+ id: "duplicate",
+ label: "Duplicate",
+ icon: Copy01Icon,
+ variant: "outline" as const,
+ },
+ {
+ id: "delete",
+ label: "Delete",
+ icon: Delete02Icon,
+ variant: "destructive-foreground" as const,
+ },
+] as const;
+
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: EditorCore;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
+
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: EditorCore;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
+}
+
+async function renameProject({
+ editor,
+ id,
+ name,
+}: {
+ editor: EditorCore;
+ id: string;
+ name: string;
+}) {
+ await editor.project.renameProject({ id, name });
+}
+
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
+
+ const actionHandlers: Record<string, () => void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2.5 px-3">
+ <div className="hidden sm:flex items-center gap-2.5">
+ {PROJECT_ACTIONS.map((action) => (
+ <Button
+ key={action.id}
+ size="icon"
+ variant={action.variant}
+ className="size-9"
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ </Button>
+ ))}
+ </div>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild className="sm:hidden">
+ <Button size="icon" variant="outline" className="size-9">
+ <HugeiconsIcon icon={MoreHorizontalIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {PROJECT_ACTIONS.map((action) => (
+ <DropdownMenuItem
+ key={action.id}
+ variant={action.id === "delete" ? "destructive" : undefined}
+ onClick={actionHandlers[action.id]}
+ >
+ <HugeiconsIcon icon={action.icon} />
+ {action.label}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={selectedProjectNames}
+ onConfirm={handleDeleteConfirm}
+ />
+ </>
+ );
+}
+
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="center">
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "createdAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "updatedAt"}
+ onCheckedChange={() => setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "name"}
+ onCheckedChange={() => setSortKey({ sortKey: "name" })}
+ >
+ Name
+ </DropdownMenuCheckboxItem>
+ <DropdownMenuCheckboxItem
+ checked={sortKey === "duration"}
+ onCheckedChange={() => setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+ </DropdownMenuCheckboxItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+function NewProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+ <Button
+ size="lg"
+ className="flex px-5 md:px-6"
+ onClick={handleCreateProject}
+ >
+ <span className="text-sm font-medium hidden md:block">New project</span>
+ <span className="text-sm font-medium block md:hidden">New</span>
+ </Button>
+ );
+}
+
+function ProjectItem({
+ project,
+ allProjectIds,
+}: {
+ project: TProjectMetadata;
+ allProjectIds: string[];
+}) {
+ const {
+ selectedProjectIds,
+ viewMode,
+ setProjectSelected,
+ selectProjectRange,
+ } = useProjectsStore();
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+ const isSelected = selectedProjectIdSet.has(project.id);
+ const selectedProjectCount = selectedProjectIds.length;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+ const editor = useEditor();
+ const durationLabel = formatProjectDuration({ duration: project.duration });
+ const isMultiSelect = selectedProjectCount > 1;
+ const isGridView = viewMode === "grid";
+
+ const handleRename = () => setIsRenameDialogOpen(true);
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: [project.id] });
+ };
+ const handleDeleteClick = () => setIsDeleteDialogOpen(true);
+ const handleInfoClick = () => setIsInfoDialogOpen(true);
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: [project.id] });
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleCheckboxChange = ({
+ checked,
+ shiftKey,
+ }: {
+ checked: boolean;
+ shiftKey: boolean;
+ }) => {
+ if (shiftKey && checked) {
+ selectProjectRange({ projectId: project.id, allProjectIds });
+ return;
+ }
+ setProjectSelected({ projectId: project.id, isSelected: checked });
+ };
+
+ const gridContent = (
+ <Card className="bg-background overflow-hidden border-none p-0">
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-12 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ {durationLabel && (
+ <div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
+ {durationLabel}
+ </div>
+ )}
+ </div>
+
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
+ {project.name}
+ </h3>
+ <div className="text-muted-foreground flex items-center gap-1.5 text-sm">
+ <HugeiconsIcon icon={Calendar04Icon} className="size-4" />
+ <span>Created {formatDate({ date: project.createdAt })}</span>
+ </div>
+ </CardContent>
+ </Card>
+ );
+
+ const listRowContent = (
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <div className="bg-muted relative size-10 rounded overflow-hidden shrink-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="flex size-full items-center justify-center">
+ <OcVideoIcon className="text-muted-foreground size-5 shrink-0" />
+ </div>
+ )}
+ </div>
+
+ <h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
+ {project.name}
+ </h3>
+
+ <span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
+ {durationLabel ?? "—"}
+ </span>
+
+ <span className="text-muted-foreground text-sm shrink-0 w-auto pl-8 text-right hidden xs:block">
+ {formatDate({ date: project.createdAt })}
+ </span>
+ </div>
+ );
+
+ const listContent = (
+ <div
+ className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
+ isSelected ? "bg-primary/5" : ""
+ }`}
+ >
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className="size-5 shrink-0"
+ />
+
+ <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
+ {listRowContent}
+ </Link>
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ variant="list"
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ )}
+ </div>
+ );
+
+ return (
+ <>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div className="group relative">
+ {isGridView ? (
+ <>
+ <Link href={`/editor/${project.id}`} className="block">
+ {gridContent}
+ </Link>
+
+ <Checkbox
+ checked={isSelected}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className={`absolute z-10 size-5 top-3 left-3 ${
+ isSelected || isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ />
+
+ {!isMultiSelect && (
+ <ProjectMenu
+ isOpen={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ )}
+ </>
+ ) : (
+ listContent
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ProjectContextMenuContent
+ onRenameClick={handleRename}
+ onDuplicateClick={handleDuplicate}
+ onDeleteClick={handleDeleteClick}
+ onInfoClick={handleInfoClick}
+ />
+ </ContextMenu>
+
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ projectName={project.name}
+ onConfirm={async (newName) => {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ projectNames={[project.name]}
+ onConfirm={handleDeleteConfirm}
+ />
+
+ <ProjectInfoDialog
+ isOpen={isInfoDialogOpen}
+ onOpenChange={setIsInfoDialogOpen}
+ project={project}
+ />
+ </>
+ );
+}
+
+function ProjectContextMenuContent({
+ onRenameClick,
+ onDuplicateClick,
+ onDeleteClick,
+ onInfoClick,
+}: {
+ onRenameClick: () => void;
+ onDuplicateClick: () => void;
+ onDeleteClick: () => void;
+ onInfoClick: () => void;
+}) {
+ return (
+ <ContextMenuContent>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Edit03Icon} />}
+ onClick={onRenameClick}
+ >
+ Rename
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ onClick={onDuplicateClick}
+ >
+ Duplicate
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={InformationCircleIcon} />}
+ onClick={onInfoClick}
+ >
+ Info
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={onDeleteClick}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ );
+}
+
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ variant = "grid",
+ onRenameClick,
+ onDuplicateClick,
+ onDeleteClick,
+ onInfoClick,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ variant?: "grid" | "list";
+ onRenameClick: () => void;
+ onDuplicateClick: () => void;
+ onDeleteClick: () => void;
+ onInfoClick: () => void;
+}) {
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent<HTMLButtonElement>;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleRename = () => {
+ onRenameClick();
+ onOpenChange(false);
+ };
+
+ const handleDuplicate = () => {
+ onDuplicateClick();
+ onOpenChange(false);
+ };
+
+ const handleDeleteClick = () => {
+ onDeleteClick();
+ onOpenChange(false);
+ };
+
+ const handleInfoClick = () => {
+ onInfoClick();
+ onOpenChange(false);
+ };
+
+ const isGrid = variant === "grid";
+
+ return (
+ <DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="background"
+ className={
+ isGrid
+ ? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
+ : "!bg-transparent !shadow-none"
+ }
+ size="icon"
+ aria-label="Project menu"
+ onClick={(event) =>
+ handleMenuClick({
+ event: event as unknown as MouseEvent<HTMLButtonElement>,
+ })
+ }
+ onMouseDown={(event) => event.stopPropagation()}
+ onKeyDown={(event) =>
+ handleMenuKeyDown({
+ event: event as unknown as KeyboardEvent<HTMLButtonElement>,
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={MoreHorizontalIcon}
+ className="text-foreground"
+ aria-hidden="true"
+ />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuItem onClick={handleRename}>
+ <HugeiconsIcon icon={Edit03Icon} />
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDuplicate}>
+ <HugeiconsIcon icon={Copy01Icon} />
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleInfoClick}>
+ <HugeiconsIcon icon={InformationCircleIcon} />
+ Info
+ </DropdownMenuItem>
+ <DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, index) => `skeleton-${index}`,
+ );
+
+ return (
+ <div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
+ {skeletonIds.map((skeletonId) => (
+ <Card
+ key={skeletonId}
+ className="bg-background overflow-hidden border-none p-0"
+ >
+ <div className="bg-muted relative aspect-video">
+ <div className="absolute inset-0">
+ <Skeleton className="bg-muted/50 size-full" />
+ </div>
+ </div>
+ <CardContent className="flex flex-col gap-2 px-0 pt-4">
+ <Skeleton className="bg-muted/50 h-4 w-3/4" />
+ <div className="text-muted-foreground flex items-center gap-1.5">
+ <Skeleton className="bg-muted/50 size-4" />
+ <Skeleton className="bg-muted/50 h-4 w-24" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+function EmptyState() {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+ const router = useRouter();
+ const editor = useEditor();
+ const savedProjects = editor.project.getSavedProjects();
+
+ const handleCreateProject = async () => {
+ try {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ } catch (error) {
+ toast.error("Failed to create project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ };
+
+ if (savedProjects.length > 0) {
+ return (
+ <div className="flex flex-col items-center justify-center gap-5 py-16 text-center">
+ <div className="flex flex-col items-center gap-8">
+ <HugeiconsIcon
+ icon={Search01Icon}
+ className="text-muted-foreground size-16 bg-accent/35 border rounded-md p-4"
+ />
+ <div className="flex flex-col items-center gap-3">
+ <h3 className="text-lg font-medium">No results found</h3>
+ <p className="text-muted-foreground max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ </div>
+ </div>
+ <Button
+ onClick={() => setSearchQuery({ query: "" })}
+ variant="outline"
+ size="lg"
+ >
+ Clear search
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
+ <div className="flex flex-col items-center gap-2">
+ <div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
+ <HugeiconsIcon
+ icon={Video01Icon}
+ className="text-muted-foreground size-8"
+ />
+ </div>
+ <h3 className="text-lg font-medium">No projects yet</h3>
+ <p className="text-muted-foreground max-w-md">
+ Start creating your first project. Import media, edit, and export your
+ videos. All privately.
+ </p>
+ </div>
+ <Button size="lg" className="gap-2" onClick={handleCreateProject}>
+ <HugeiconsIcon icon={PlusSignIcon} />
+ Create your first project
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index 0578eeb7..1526a842 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -1,369 +1,369 @@
-"use client";
-
-import { useRef, useState } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { BackgroundContent } from "./background";
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { useEditorStore } from "@/stores/editor-store";
-import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Tick02Icon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { formatNumberForDisplay } from "@/utils/math";
-import { OcSquarePlusIcon } from "@opencut/ui/icons";
-import type { TCanvasSize } from "@/lib/project/types";
-
-type SettingsView = "project-info" | "background";
-
-const PRESET_LABELS: Record<string, string> = {
- "1:1": "1:1",
- "16:9": "16:9",
- "9:16": "9:16",
- "4:3": "4:3",
-};
-
-function areCanvasSizesEqual({
- left,
- right,
-}: {
- left: TCanvasSize;
- right: TCanvasSize;
-}) {
- return left.width === right.width && left.height === right.height;
-}
-
-function formatCanvasDimension({ value }: { value: number }) {
- return formatNumberForDisplay({ value, maxFractionDigits: 0 });
-}
-
-function parseCanvasDimension({ input }: { input: string }): number | null {
- const trimmed = input.trim();
- if (!trimmed) return null;
-
- const parsed = Number(trimmed);
- if (!Number.isFinite(parsed)) return null;
-
- const rounded = Math.round(parsed);
- return rounded > 0 ? rounded : null;
-}
-
-function useCanvasDimensionDraft({
- value,
- onCommit,
-}: {
- value: number;
- onCommit: (value: number) => void;
-}) {
- const pendingValueRef = useRef(value);
- const syncedValueRef = useRef(value);
-
- if (syncedValueRef.current !== value) {
- syncedValueRef.current = value;
- pendingValueRef.current = value;
- }
-
- return usePropertyDraft({
- displayValue: formatCanvasDimension({ value }),
- parse: (input) => parseCanvasDimension({ input }),
- onPreview: (nextValue) => {
- pendingValueRef.current = nextValue;
- },
- onCommit: () => {
- if (pendingValueRef.current !== value) {
- onCommit(pendingValueRef.current);
- }
- },
- });
-}
-
-export function SettingsView() {
- const [view, setView] = useState<SettingsView>("project-info");
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
- const { canvasPresets } = useEditorStore();
- const currentCanvasSize = activeProject.settings.canvasSize;
- const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
- const lastCustomCanvasSize =
- activeProject.settings.lastCustomCanvasSize ?? null;
-
- const presetItems = canvasPresets.map((preset, index) => {
- const ratio = dimensionToAspectRatio(preset);
- return {
- id: index.toString(),
- label: PRESET_LABELS[ratio] ?? ratio,
- ratio,
- canvasSize: preset,
- };
- });
-
- const selectedPresetId = canvasSizeMode === "preset"
- ? (presetItems.find((preset) =>
- areCanvasSizesEqual({
- left: preset.canvasSize,
- right: currentCanvasSize,
- }),
- )?.id ?? null)
- : null;
-
- const updateCustomCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateLastCustomCanvasSize =
- lastCustomCanvasSize === null ||
- !areCanvasSizesEqual({
- left: canvasSize,
- right: lastCustomCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
-
- if (
- !shouldUpdateCanvasSize &&
- !shouldUpdateLastCustomCanvasSize &&
- !shouldUpdateCanvasSizeMode
- ) {
- return;
- }
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "custom" as const }
- : {}),
- lastCustomCanvasSize: canvasSize,
- },
- });
- };
-
- const selectPresetCanvasSize = ({
- canvasSize,
- }: {
- canvasSize: TCanvasSize;
- }) => {
- const shouldUpdateCanvasSize = !areCanvasSizesEqual({
- left: canvasSize,
- right: currentCanvasSize,
- });
- const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
-
- if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
-
- editor.project.updateSettings({
- settings: {
- ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
- ...(shouldUpdateCanvasSizeMode
- ? { canvasSizeMode: "preset" as const }
- : {}),
- },
- });
- };
-
- const selectCustomCanvasSize = () => {
- updateCustomCanvasSize({
- canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
- });
- };
-
- const widthDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.width,
- onCommit: (width) =>
- updateCustomCanvasSize({
- canvasSize: { width, height: currentCanvasSize.height },
- }),
- });
-
- const heightDraft = useCanvasDimensionDraft({
- value: currentCanvasSize.height,
- onCommit: (height) =>
- updateCustomCanvasSize({
- canvasSize: { width: currentCanvasSize.width, height },
- }),
- });
-
- const isCustomSelected = canvasSizeMode === "custom";
-
- return (
- <PanelView
- contentClassName="px-0"
- scrollClassName="pt-0"
- actions={
- <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
- <TabsList>
- <TabsTrigger value="project-info">Project info</TabsTrigger>
- <TabsTrigger value="background">Background</TabsTrigger>
- </TabsList>
- </Tabs>
- }
- >
- {view === "project-info" && (
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle className="flex-1">Name</SectionTitle>
- <span className="text-sm truncate">
- {activeProject.metadata.name}
- </span>
- </SectionHeader>
- </Section>
- <Section showTopBorder={false}>
- <SectionHeader className="justify-between">
- <SectionTitle className="flex-1">Frame rate</SectionTitle>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- }}
- >
- <SelectTrigger className="bg-transparent border-none p-1 h-auto">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </SectionHeader>
- </Section>
- <Section
- showTopBorder={false}
- collapsible
- sectionKey="settings:aspect-ratio"
- >
- <SectionHeader>
- <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
- </SectionHeader>
- <SectionContent className="px-2 flex flex-col gap-1 pb-2">
- {presetItems.map((preset) => (
- <AspectRatioItem
- key={preset.id}
- label={preset.label}
- previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
- isSelected={selectedPresetId === preset.id}
- onClick={() => {
- selectPresetCanvasSize({
- canvasSize: preset.canvasSize,
- });
- }}
- />
- ))}
- <div className="pb-2">
- <AspectRatioItem
- key="custom"
- label="Custom"
- previewIcon={<OcSquarePlusIcon />}
- isSelected={isCustomSelected}
- onClick={selectCustomCanvasSize}
- uiOptions={
- <div className=" flex items-center gap-2 text-foreground">
- <NumberField
- value={widthDraft.displayValue}
- className="w-full"
- aria-label="Canvas width"
- onFocus={widthDraft.onFocus}
- onChange={widthDraft.onChange}
- onBlur={widthDraft.onBlur}
- />
- <NumberField
- value={heightDraft.displayValue}
- className="w-full"
- aria-label="Canvas height"
- onFocus={heightDraft.onFocus}
- onChange={heightDraft.onChange}
- onBlur={heightDraft.onBlur}
- />
- </div>
- }
- />
- </div>
- </SectionContent>
- </Section>
- </div>
- )}
- {view === "background" && <BackgroundContent />}
- </PanelView>
- );
-}
-
-function AspectRatioItem({
- label,
- previewIcon,
- isSelected,
- onClick,
- uiOptions,
-}: {
- label: string;
- previewIcon: React.ReactNode;
- isSelected: boolean;
- onClick: () => void;
- uiOptions?: React.ReactNode;
-}) {
- return (
- <Button
- variant={isSelected ? "secondary" : "ghost"}
- className={cn(
- "px-2 py-0 flex flex-col h-fit w-full",
- !isSelected && "border border-transparent opacity-75!",
- )}
- onClick={onClick}
- >
- <div className="w-full flex justify-between items-center h-8">
- <div className="flex-1 flex items-center gap-2">
- <div className="flex items-center justify-center size-5">
- {previewIcon}
- </div>
- <span className="text-sm truncate">{label}</span>
- </div>
- <div>
- {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
- </div>
- </div>
- {uiOptions && isSelected && (
- <div className="w-full pb-2">{uiOptions}</div>
- )}
- </Button>
- );
-}
-
-function AspectRatioPreview({ ratio }: { ratio?: string }) {
- if (!ratio) return null;
-
- const [w, h] = ratio.split(":").map(Number);
- const maxSize = 16;
- const width = w >= h ? maxSize : (w / h) * maxSize;
- const height = h >= w ? maxSize : (h / w) * maxSize;
-
- return (
- <div
- style={{ width, height, borderWidth: 1.5 }}
- className="rounded-xs border-current opacity-60"
- />
- );
-}
+"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@/components/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
index 38d0a189..df6c0e52 100644
--- a/apps/web/src/components/editor/panels/properties/registry.tsx
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -1,303 +1,303 @@
-import type { ReactNode } from "react";
-import type {
- EffectElement,
- GraphicElement,
- ImageElement,
- MaskableElement,
- RetimableElement,
- StickerElement,
- TextElement,
- VisualElement,
- VideoElement,
- AudioElement,
- TimelineElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- TextFontIcon,
- ArrowExpandIcon,
- RainDropIcon,
- MusicNote03Icon,
- MagicWand05Icon,
- DashboardSpeed02Icon,
-} from "@hugeicons/core-free-icons";
-import { TransformTab } from "./tabs/transform-tab";
-import { BlendingTab } from "./tabs/blending-tab";
-import { AudioTab } from "./tabs/audio-tab";
-import { TextTab } from "./tabs/text-tab";
-import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
-import { MasksTab } from "./tabs/masks-tab";
-import { SpeedTab } from "./tabs/speed-tab";
-import { GraphicTab } from "./tabs/graphic-tab";
-import { OcShapesIcon } from "@opencut/ui/icons";
-
-export type TabContentProps = {
- trackId: string;
-};
-
-export type PropertiesTabDef = {
- id: string;
- label: string;
- icon: ReactNode;
- content: (props: TabContentProps) => ReactNode;
-};
-
-export type ElementPropertiesConfig = {
- defaultTab: string;
- tabs: PropertiesTabDef[];
-};
-
-function buildTransformTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "transform",
- label: "Transform",
- icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
- content: ({ trackId }) => (
- <TransformTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildBlendingTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "blending",
- label: "Blending",
- icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
- content: ({ trackId }) => (
- <BlendingTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildAudioTab({
- element,
-}: {
- element: AudioElement | VideoElement;
-}): PropertiesTabDef {
- return {
- id: "audio",
- label: "Audio",
- icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
- content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
- };
-}
-
-function buildSpeedTab({
- element,
-}: {
- element: RetimableElement;
-}): PropertiesTabDef {
- return {
- id: "speed",
- label: "Speed",
- icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
- content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
- };
-}
-
-function buildMasksTab({
- element,
-}: {
- element: MaskableElement;
-}): PropertiesTabDef {
- return {
- id: "masks",
- label: "Masks",
- icon: <OcShapesIcon size={16} />,
- content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
- };
-}
-
-function buildClipEffectsTab({
- element,
-}: {
- element: VisualElement;
-}): PropertiesTabDef {
- return {
- id: "effects",
- label: "Effects",
- icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
- content: ({ trackId }) => (
- <ClipEffectsTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
- return {
- id: "text",
- label: "Text",
- icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
- content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
- };
-}
-
-function buildGraphicTab({
- element,
-}: {
- element: GraphicElement;
-}): PropertiesTabDef {
- return {
- id: "graphic",
- label: "Graphic",
- icon: <OcShapesIcon size={16} />,
- content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
- };
-}
-
-function buildStandaloneEffectTab({
- element,
-}: {
- element: EffectElement;
-}): PropertiesTabDef {
- return {
- id: "effects",
- label: "Effects",
- icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
- content: ({ trackId }) => (
- <StandaloneEffectTab element={element} trackId={trackId} />
- ),
- };
-}
-
-function getTextConfig({
- element,
-}: {
- element: TextElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "text",
- tabs: [
- buildTextTab({ element }),
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- ],
- };
-}
-
-function getVideoConfig({
- element,
- mediaAsset,
-}: {
- element: VideoElement;
- mediaAsset: MediaAsset | undefined;
-}): ElementPropertiesConfig {
- const showAudioTab = mediaAsset?.hasAudio !== false;
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- ...(showAudioTab ? [buildAudioTab({ element })] : []),
- buildSpeedTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getImageConfig({
- element,
-}: {
- element: ImageElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getStickerConfig({
- element,
-}: {
- element: StickerElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "transform",
- tabs: [
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getGraphicConfig({
- element,
-}: {
- element: GraphicElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "graphic",
- tabs: [
- buildGraphicTab({ element }),
- buildTransformTab({ element }),
- buildBlendingTab({ element }),
- buildMasksTab({ element }),
- buildClipEffectsTab({ element }),
- ],
- };
-}
-
-function getAudioConfig({
- element,
-}: {
- element: AudioElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "audio",
- tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
- };
-}
-
-function getEffectConfig({
- element,
-}: {
- element: EffectElement;
-}): ElementPropertiesConfig {
- return {
- defaultTab: "effects",
- tabs: [buildStandaloneEffectTab({ element })],
- };
-}
-
-export function getPropertiesConfig({
- element,
- mediaAssets,
-}: {
- element: TimelineElement;
- mediaAssets: MediaAsset[];
-}): ElementPropertiesConfig {
- switch (element.type) {
- case "text":
- return getTextConfig({ element });
- case "video": {
- const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
- return getVideoConfig({ element, mediaAsset });
- }
- case "image":
- return getImageConfig({ element });
- case "sticker":
- return getStickerConfig({ element });
- case "graphic":
- return getGraphicConfig({ element });
- case "audio":
- return getAudioConfig({ element });
- case "effect":
- return getEffectConfig({ element });
- }
-}
+import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@/components/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
index fdcde148..a69acebf 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -1,230 +1,230 @@
-import { useEditor } from "@/hooks/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@opencut/ui/icons";
-import { Fragment, useRef } from "react";
-import { useMenuPreview } from "@/hooks/use-menu-preview";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/lib/rendering";
-import type { ElementType } from "@/lib/timeline";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { isPropertyAtDefault } from "./transform-tab";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-const BLEND_MODE_GROUPS = [
- [{ value: "normal", label: "Normal" }],
- [
- { value: "darken", label: "Darken" },
- { value: "multiply", label: "Multiply" },
- { value: "color-burn", label: "Color Burn" },
- ],
- [
- { value: "lighten", label: "Lighten" },
- { value: "screen", label: "Screen" },
- { value: "plus-lighter", label: "Plus Lighter" },
- { value: "color-dodge", label: "Color Dodge" },
- ],
- [
- { value: "overlay", label: "Overlay" },
- { value: "soft-light", label: "Soft Light" },
- { value: "hard-light", label: "Hard Light" },
- ],
- [
- { value: "difference", label: "Difference" },
- { value: "exclusion", label: "Exclusion" },
- ],
- [
- { value: "hue", label: "Hue" },
- { value: "saturation", label: "Saturation" },
- { value: "color", label: "Color" },
- { value: "luminosity", label: "Luminosity" },
- ],
-];
-
-export function BlendingTab({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
- const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
- const committedBlendModeRef = useRef(blendMode);
- if (!isPreviewActive) {
- committedBlendModeRef.current = blendMode;
- }
-
- const {
- onPointerLeave,
- onOpenChange: handleBlendModeOpenChange,
- markCommitted,
- } = useMenuPreview();
-
- const previewBlendMode = ({ value }: { value: BlendMode }) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { blendMode: value } },
- ],
- });
-
- const commitBlendMode = (value: string) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { blendMode: value as BlendMode },
- },
- ],
- });
- }
- markCommitted();
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:blending`}>
- <SectionHeader>
- <SectionTitle>Blending</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() =>
- opacity.commitValue({ value: DEFAULTS.element.opacity })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULTS.element.opacity,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
+import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { NumberField } from "@/components/ui/number-field";
+import { OcCheckerboardIcon } from "@/components/icons";
+import { Fragment, useRef } from "react";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingTab({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
+ const committedBlendModeRef = useRef(blendMode);
+ if (!isPreviewActive) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ markCommitted();
+ };
+
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
index db8bd728..d416dc28 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -53,7 +53,7 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
-import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index be85bede..4c58023b 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -1,765 +1,765 @@
-"use client";
-
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/lib/timeline";
-import { NumberField } from "@/components/ui/number-field";
-import { useRef } from "react";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_COLOR } from "@/constants/project-constants";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/constants/text-constants";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "../hooks/use-element-playhead";
-import { KeyframeToggle } from "../components/keyframe-toggle";
-import { isPropertyAtDefault } from "./transform-tab";
-import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- MinusSignIcon,
- PlusSignIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { cn } from "@/utils/ui";
-
-export function TextTab({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- return (
- <div className="flex flex-col">
- <ContentSection element={element} trackId={trackId} />
- <TypographySection element={element} trackId={trackId} />
- <SpacingSection element={element} trackId={trackId} />
- <BackgroundSection element={element} trackId={trackId} />
- </div>
- );
-}
-
-function ContentSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const content = usePropertyDraft({
- displayValue: element.content,
- parse: (input) => input,
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { content: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:content`}>
- <SectionHeader>
- <SectionTitle>Content</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <Textarea
- placeholder="Name"
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function TypographySection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTextColor = resolveColorAtTime({
- baseColor: element.color,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- });
-
- const textColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedTextColor,
- buildBaseUpdates: ({ value }) => ({ color: value }),
- });
-
- const fontSize = usePropertyDraft({
- displayValue: element.fontSize.toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- });
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { fontSize: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:typography`}>
- <SectionHeader>
- <SectionTitle>Typography</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontSize: DEFAULTS.text.element.fontSize,
- },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={textColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle text color keyframe"
- onToggle={textColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={uppercase({
- string: resolvedTextColor.replace("#", ""),
- })}
- onChange={(color) => textColor.onChange({ color: `#${color}` })}
- onChangeEnd={textColor.onChangeEnd}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function SpacingSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const letterSpacing = usePropertyDraft({
- displayValue: Math.round(
- element.letterSpacing ?? DEFAULTS.text.letterSpacing,
- ).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { letterSpacing: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const lineHeight = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: element.lineHeight ?? DEFAULTS.text.lineHeight,
- fractionDigits: 1,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed)
- ? null
- : Math.max(0.1, Math.round(parsed * 10) / 10);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { lineHeight: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:spacing`}>
- <SectionHeader>
- <SectionTitle>Spacing</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { letterSpacing: DEFAULTS.text.letterSpacing },
- },
- ],
- })
- }
- isDefault={
- (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
- DEFAULTS.text.letterSpacing
- }
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { lineHeight: DEFAULTS.text.lineHeight },
- },
- ],
- })
- }
- isDefault={
- (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
- DEFAULTS.text.lineHeight
- }
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function BackgroundSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_COLOR);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedBgColor = resolveColorAtTime({
- baseColor: element.background.color,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- });
-
- const bgColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedBgColor,
- buildBaseUpdates: ({ value }) => ({
- background: { ...element.background, color: value },
- }),
- });
-
- const bg = element.background;
-
- const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- });
- const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- });
- const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- });
- const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- });
- const resolvedCornerRadius = resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- });
-
- const paddingX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingX: value },
- }),
- });
-
- const paddingY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingY: value },
- }),
- });
-
- const offsetX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetX: value },
- }),
- });
-
- const offsetY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetY: value },
- }),
- });
-
- const cornerRadius = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedCornerRadius).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- });
- },
- valueAtPlayhead: resolvedCornerRadius,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, cornerRadius: value },
- }),
- });
-
- const toggleBackgroundEnabled = () => {
- const enabled = !element.background.enabled;
- const color =
- enabled && element.background.color === "transparent"
- ? lastSelectedColor.current
- : element.background.color;
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- enabled,
- color,
- },
- },
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- defaultOpen={element.background.enabled}
- sectionKey={`${element.id}:background`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleBackgroundEnabled();
- }}
- >
- <HugeiconsIcon
- icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(
- !element.background.enabled && "pointer-events-none opacity-50",
- )}
- >
- <SectionFields>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={bgColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background color keyframe"
- onToggle={bgColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={
- !element.background.enabled ||
- element.background.color === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : resolvedBgColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- bgColor.onChange({ color: hexColor });
- }}
- onChangeEnd={bgColor.onChangeEnd}
- />
- </SectionField>
- <div className="flex items-start gap-2">
- <SectionField
- label="Width"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background width keyframe"
- onToggle={paddingX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
- onFocus={paddingX.onFocus}
- onChange={paddingX.onChange}
- onBlur={paddingX.onBlur}
- onScrub={paddingX.scrubTo}
- onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- paddingX.commitValue({
- value: DEFAULTS.text.background.paddingX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- defaultValue: DEFAULTS.text.background.paddingX,
- })}
- />
- </SectionField>
- <SectionField
- label="Height"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background height keyframe"
- onToggle={paddingY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
- onFocus={paddingY.onFocus}
- onChange={paddingY.onChange}
- onBlur={paddingY.onBlur}
- onScrub={paddingY.scrubTo}
- onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- paddingY.commitValue({
- value: DEFAULTS.text.background.paddingY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- defaultValue: DEFAULTS.text.background.paddingY,
- })}
- />
- </SectionField>
- </div>
- <div className="flex items-start gap-2">
- <SectionField
- label="X-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle x-offset keyframe"
- onToggle={offsetX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={offsetX.displayValue}
- onFocus={offsetX.onFocus}
- onChange={offsetX.onChange}
- onBlur={offsetX.onBlur}
- onScrub={offsetX.scrubTo}
- onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- offsetX.commitValue({
- value: DEFAULTS.text.background.offsetX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- defaultValue: DEFAULTS.text.background.offsetX,
- })}
- />
- </SectionField>
- <SectionField
- label="Y-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle y-offset keyframe"
- onToggle={offsetY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
- onFocus={offsetY.onFocus}
- onChange={offsetY.onChange}
- onBlur={offsetY.onBlur}
- onScrub={offsetY.scrubTo}
- onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- offsetY.commitValue({
- value: DEFAULTS.text.background.offsetY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- defaultValue: DEFAULTS.text.background.offsetY,
- })}
- />
- </SectionField>
- </div>
- <SectionField
- label="Corner radius"
- beforeLabel={
- <KeyframeToggle
- isActive={cornerRadius.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle corner radius keyframe"
- onToggle={cornerRadius.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={CORNER_RADIUS_MIN}
- max={CORNER_RADIUS_MAX}
- onFocus={cornerRadius.onFocus}
- onChange={cornerRadius.onChange}
- onBlur={cornerRadius.onBlur}
- onScrub={cornerRadius.scrubTo}
- onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedCornerRadius,
- staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- defaultValue: CORNER_RADIUS_MIN,
- })}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
+"use client";
+
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { TextElement } from "@/lib/timeline";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { uppercase } from "@/utils/string";
+import { clamp, formatNumberForDisplay } from "@/utils/math";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ MinusSignIcon,
+ PlusSignIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { cn } from "@/utils/ui";
+
+export function TextTab({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ return (
+ <div className="flex flex-col">
+ <ContentSection element={element} trackId={trackId} />
+ <TypographySection element={element} trackId={trackId} />
+ <SpacingSection element={element} trackId={trackId} />
+ <BackgroundSection element={element} trackId={trackId} />
+ </div>
+ );
+}
+
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { content: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:content`}>
+ <SectionHeader>
+ <SectionTitle>Content</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <Textarea
+ placeholder="Name"
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function TypographySection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
+
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { fontSize: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:typography`}>
+ <SectionHeader>
+ <SectionTitle>Typography</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <NumberField
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onScrub={fontSize.scrubTo}
+ onScrubEnd={fontSize.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={textColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle text color keyframe"
+ onToggle={textColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={uppercase({
+ string: resolvedTextColor.replace("#", ""),
+ })}
+ onChange={(color) => textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
+ ).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const lineHeight = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: element.lineHeight ?? DEFAULTS.text.lineHeight,
+ fractionDigits: 1,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
+ <SectionHeader>
+ <SectionTitle>Spacing</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Letter spacing" className="w-1/2">
+ <NumberField
+ value={letterSpacing.displayValue}
+ onFocus={letterSpacing.onFocus}
+ onChange={letterSpacing.onChange}
+ onBlur={letterSpacing.onBlur}
+ onScrub={letterSpacing.scrubTo}
+ onScrubEnd={letterSpacing.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
+ }
+ icon={<OcTextWidthIcon size={14} />}
+ />
+ </SectionField>
+ <SectionField label="Line height" className="w-1/2">
+ <NumberField
+ value={lineHeight.displayValue}
+ onFocus={lineHeight.onFocus}
+ onChange={lineHeight.onChange}
+ onBlur={lineHeight.onBlur}
+ onScrub={lineHeight.scrubTo}
+ onScrubEnd={lineHeight.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
+ }
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
+
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
+
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
+ });
+
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
+ });
+
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
+ });
+
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
+ });
+
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
+ });
+
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey={`${element.id}:background`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
+ <SectionFields>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={bgColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background color keyframe"
+ onToggle={bgColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={
+ !element.background.enabled ||
+ element.background.color === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : resolvedBgColor.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ bgColor.onChange({ color: hexColor });
+ }}
+ onChangeEnd={bgColor.onChangeEnd}
+ />
+ </SectionField>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Width"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background width keyframe"
+ onToggle={paddingX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
+ onFocus={paddingX.onFocus}
+ onChange={paddingX.onChange}
+ onBlur={paddingX.onBlur}
+ onScrub={paddingX.scrubTo}
+ onScrubEnd={paddingX.commitScrub}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Height"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background height keyframe"
+ onToggle={paddingY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
+ onFocus={paddingY.onFocus}
+ onChange={paddingY.onChange}
+ onBlur={paddingY.onBlur}
+ onScrub={paddingY.scrubTo}
+ onScrubEnd={paddingY.commitScrub}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="X-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle x-offset keyframe"
+ onToggle={offsetX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
+ onFocus={offsetX.onFocus}
+ onChange={offsetX.onChange}
+ onBlur={offsetX.onBlur}
+ onScrub={offsetX.scrubTo}
+ onScrubEnd={offsetX.commitScrub}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Y-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle y-offset keyframe"
+ onToggle={offsetY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
+ onFocus={offsetY.onFocus}
+ onChange={offsetY.onChange}
+ onBlur={offsetY.onBlur}
+ onScrub={offsetY.scrubTo}
+ onScrubEnd={offsetY.commitScrub}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <SectionField
+ label="Corner radius"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={cornerRadius.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle corner radius keyframe"
+ onToggle={cornerRadius.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
+ onFocus={cornerRadius.onFocus}
+ onChange={cornerRadius.onChange}
+ onBlur={cornerRadius.onBlur}
+ onScrub={cornerRadius.scrubTo}
+ onScrubEnd={cornerRadius.commitScrub}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index f02f8461..5b1ebb96 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -13,7 +13,7 @@ import {
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
-import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
+import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
import {
ContextMenu,
ContextMenuContent,
diff --git a/packages/ui/src/icons/brand.tsx b/apps/web/src/components/icons/brand.tsx
index 26aabf86..26aabf86 100644
--- a/packages/ui/src/icons/brand.tsx
+++ b/apps/web/src/components/icons/brand.tsx
diff --git a/packages/ui/src/icons/index.tsx b/apps/web/src/components/icons/index.tsx
index 2203b633..2203b633 100644
--- a/packages/ui/src/icons/index.tsx
+++ b/apps/web/src/components/icons/index.tsx
diff --git a/packages/ui/src/icons/ui.tsx b/apps/web/src/components/icons/ui.tsx
index 819276de..819276de 100644
--- a/packages/ui/src/icons/ui.tsx
+++ b/apps/web/src/components/icons/ui.tsx
diff --git a/apps/web/src/constants/site-constants.ts b/apps/web/src/constants/site-constants.ts
index 1dc5a9d9..ea2d2fe2 100644
--- a/apps/web/src/constants/site-constants.ts
+++ b/apps/web/src/constants/site-constants.ts
@@ -1,69 +1,69 @@
-import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
-
-export const SITE_URL = "https://opencut.app";
-
-export const SITE_INFO = {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- url: SITE_URL,
- openGraphImage: "/open-graph/default.jpg",
- twitterImage: "/open-graph/default.jpg",
- favicon: "/favicon.ico",
-};
-
-export type ExternalTool = {
- name: string;
- description: string;
- url: string;
- icon: React.ElementType;
-};
-
-export const EXTERNAL_TOOLS: ExternalTool[] = [
- {
- name: "Marble",
- description:
- "Modern headless CMS for content management and the blog for OpenCut",
- url: "https://marblecms.com?utm_source=opencut",
- icon: OcMarbleIcon,
- },
- {
- name: "Databuddy",
- description: "GDPR compliant analytics and user insights for OpenCut",
- url: "https://databuddy.cc?utm_source=opencut",
- icon: OcDataBuddyIcon,
- },
-];
-
-export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
-
-export const SOCIAL_LINKS = {
- x: "https://x.com/opencutapp",
- github: "https://github.com/OpenCut-app/OpenCut",
- discord: "https://discord.com/invite/Mu3acKZvCp",
-};
-
-export type Sponsor = {
- name: string;
- url: string;
- logo: string;
- description: string;
- invertOnDark?: boolean;
-};
-
-export const SPONSORS: Sponsor[] = [
- {
- name: "Fal.ai",
- url: "https://fal.ai?utm_source=opencut",
- logo: "/logos/others/fal.svg",
- description: "Generative image, video, and audio models all in one place.",
- invertOnDark: true,
- },
- {
- name: "Vercel",
- url: "https://vercel.com?utm_source=opencut",
- logo: "/logos/others/vercel.svg",
- description: "Platform where we deploy and host OpenCut.",
- invertOnDark: true,
- },
-];
+import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
+
+export const SITE_URL = "https://opencut.app";
+
+export const SITE_INFO = {
+ title: "OpenCut",
+ description:
+ "A simple but powerful video editor that gets the job done. In your browser.",
+ url: SITE_URL,
+ openGraphImage: "/open-graph/default.jpg",
+ twitterImage: "/open-graph/default.jpg",
+ favicon: "/favicon.ico",
+};
+
+export type ExternalTool = {
+ name: string;
+ description: string;
+ url: string;
+ icon: React.ElementType;
+};
+
+export const EXTERNAL_TOOLS: ExternalTool[] = [
+ {
+ name: "Marble",
+ description:
+ "Modern headless CMS for content management and the blog for OpenCut",
+ url: "https://marblecms.com?utm_source=opencut",
+ icon: OcMarbleIcon,
+ },
+ {
+ name: "Databuddy",
+ description: "GDPR compliant analytics and user insights for OpenCut",
+ url: "https://databuddy.cc?utm_source=opencut",
+ icon: OcDataBuddyIcon,
+ },
+];
+
+export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
+
+export const SOCIAL_LINKS = {
+ x: "https://x.com/opencutapp",
+ github: "https://github.com/OpenCut-app/OpenCut",
+ discord: "https://discord.com/invite/Mu3acKZvCp",
+};
+
+export type Sponsor = {
+ name: string;
+ url: string;
+ logo: string;
+ description: string;
+ invertOnDark?: boolean;
+};
+
+export const SPONSORS: Sponsor[] = [
+ {
+ name: "Fal.ai",
+ url: "https://fal.ai?utm_source=opencut",
+ logo: "/logos/others/fal.svg",
+ description: "Generative image, video, and audio models all in one place.",
+ invertOnDark: true,
+ },
+ {
+ name: "Vercel",
+ url: "https://vercel.com?utm_source=opencut",
+ logo: "/logos/others/vercel.svg",
+ description: "Platform where we deploy and host OpenCut.",
+ invertOnDark: true,
+ },
+];
diff --git a/apps/web/src/lib/auth/client.ts b/apps/web/src/lib/auth/client.ts
index a4380e54..3bec5072 100644
--- a/apps/web/src/lib/auth/client.ts
+++ b/apps/web/src/lib/auth/client.ts
@@ -1,6 +1,6 @@
-import { createAuthClient } from "better-auth/react";
-import { webEnv } from "@opencut/env/web";
-
-export const { signIn, signUp, useSession } = createAuthClient({
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
-});
+import { createAuthClient } from "better-auth/react";
+import { webEnv } from "@/lib/env/web";
+
+export const { signIn, signUp, useSession } = createAuthClient({
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+});
diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts
index aaca7936..39fce38f 100644
--- a/apps/web/src/lib/auth/server.ts
+++ b/apps/web/src/lib/auth/server.ts
@@ -1,43 +1,43 @@
-import { betterAuth, type RateLimit } from "better-auth";
-import { drizzleAdapter } from "better-auth/adapters/drizzle";
-import { Redis } from "@upstash/redis";
-import { db } from "@/lib/db";
-import { webEnv } from "@opencut/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const auth = betterAuth({
- database: drizzleAdapter(db, {
- provider: "pg",
- usePlural: true,
- }),
- secret: webEnv.BETTER_AUTH_SECRET,
- user: {
- deleteUser: {
- enabled: true,
- },
- },
- emailAndPassword: {
- enabled: true,
- },
- rateLimit: {
- storage: "secondary-storage",
- customStorage: {
- get: async (key) => {
- const value = await redis.get(key);
- return value as RateLimit | undefined;
- },
- set: async (key, value) => {
- await redis.set(key, value);
- },
- },
- },
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
- appName: "OpenCut",
- trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
-});
-
-export type Auth = typeof auth;
+import { betterAuth, type RateLimit } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { Redis } from "@upstash/redis";
+import { db } from "@/lib/db";
+import { webEnv } from "@/lib/env/web";
+
+const redis = new Redis({
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const auth = betterAuth({
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ usePlural: true,
+ }),
+ secret: webEnv.BETTER_AUTH_SECRET,
+ user: {
+ deleteUser: {
+ enabled: true,
+ },
+ },
+ emailAndPassword: {
+ enabled: true,
+ },
+ rateLimit: {
+ storage: "secondary-storage",
+ customStorage: {
+ get: async (key) => {
+ const value = await redis.get(key);
+ return value as RateLimit | undefined;
+ },
+ set: async (key, value) => {
+ await redis.set(key, value);
+ },
+ },
+ },
+ baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+ appName: "OpenCut",
+ trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
+});
+
+export type Auth = typeof auth;
diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts
index 290e7c2a..b1ffd119 100644
--- a/apps/web/src/lib/db/index.ts
+++ b/apps/web/src/lib/db/index.ts
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
-import { webEnv } from "@opencut/env/web";
+import { webEnv } from "@/lib/env/web";
let _db: ReturnType<typeof drizzle> | null = null;
diff --git a/packages/env/src/web.ts b/apps/web/src/lib/env/web.ts
index 4e8b550e..4e8b550e 100644
--- a/packages/env/src/web.ts
+++ b/apps/web/src/lib/env/web.ts
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index 6b59c473..234bf48f 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -1,21 +1,21 @@
-import { Ratelimit } from "@upstash/ratelimit";
-import { Redis } from "@upstash/redis";
-import { webEnv } from "@opencut/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const baseRateLimit = new Ratelimit({
- redis,
- limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
- analytics: true,
- prefix: "rate-limit",
-});
-
-export async function checkRateLimit({ request }: { request: Request }) {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- return { success, limited: !success };
-}
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+import { webEnv } from "@/lib/env/web";
+
+const redis = new Redis({
+ url: webEnv.UPSTASH_REDIS_REST_URL,
+ token: webEnv.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const baseRateLimit = new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
+ analytics: true,
+ prefix: "rate-limit",
+});
+
+export async function checkRateLimit({ request }: { request: Request }) {
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+ return { success, limited: !success };
+}
diff --git a/bun.lock b/bun.lock
index a1f52972..7e8a4bf5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -24,8 +24,6 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
- "@opencut/env": "workspace:*",
- "@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -79,7 +77,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
- "zod": "^3.25.67",
+ "zod": "^3.25.76",
"zustand": "^5.0.2",
},
"devDependencies": {
@@ -104,32 +102,6 @@
"wrangler": "^4.77.0",
},
},
- "packages/env": {
- "name": "@opencut/env",
- "version": "0.0.0",
- "dependencies": {
- "zod": "^4.0.5",
- },
- "devDependencies": {
- "@types/bun": "latest",
- "@types/node": "^24.2.1",
- "dotenv": "^16.4.7",
- "typescript": "^5.8.3",
- },
- },
- "packages/ui": {
- "name": "@opencut/ui",
- "version": "0.0.0",
- "dependencies": {
- "@iconify/react": "^6.0.2",
- "@types/react": "^19.2.7",
- "react": "^19.2.0",
- },
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.8.3",
- },
- },
},
"trustedDependencies": [
"@tailwindcss/oxide",
@@ -385,10 +357,6 @@
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
- "@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
-
- "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
-
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -509,10 +477,6 @@
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="],
- "@opencut/env": ["@opencut/env@workspace:packages/env"],
-
- "@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
-
"@opencut/web": ["@opencut/web@workspace:apps/web"],
"@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
@@ -1835,7 +1799,7 @@
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
- "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
@@ -1851,6 +1815,8 @@
"@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
+ "@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -1871,8 +1837,6 @@
"@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
- "@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -1989,6 +1953,10 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
+ "better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+
+ "better-call/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+
"body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
diff --git a/packages/env/package.json b/packages/env/package.json
deleted file mode 100644
index c16b5bc1..00000000
--- a/packages/env/package.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "@opencut/env",
- "version": "0.0.0",
- "description": "Environment package for OpenCut",
- "main": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- ".": "./src/index.ts",
- "./web": "./src/web.ts",
- "./tools": "./src/tools.ts"
- },
- "dependencies": {
- "zod": "^4.0.5"
- },
- "devDependencies": {
- "dotenv": "^16.4.7",
- "@types/bun": "latest",
- "@types/node": "^24.2.1",
- "typescript": "^5.8.3"
- }
-}
diff --git a/packages/ui/package.json b/packages/ui/package.json
deleted file mode 100644
index e4f2d3ae..00000000
--- a/packages/ui/package.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "@opencut/ui",
- "version": "0.0.0",
- "description": "UI package for OpenCut",
- "main": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- "./icons": "./src/icons/index.tsx"
- },
- "dependencies": {
- "@iconify/react": "^6.0.2",
- "@types/react": "^19.2.7",
- "react": "^19.2.0"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.8.3"
- }
-}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
deleted file mode 100644
index 6f06d719..00000000
--- a/packages/ui/tsconfig.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src/**/*"],
- "exclude": ["node_modules"]
-}