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, -) { - 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, +) { + 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 ( -
- - - - p.id)} /> -
- {isLoading || !isInitialized ? ( - - ) : projectsToDisplay.length === 0 ? ( - - ) : ( -
- {projectsToDisplay.map((project) => ( - p.id)} - /> - ))} -
- )} -
-
- ); -} - -function ProjectsHeader() { - const { viewMode, isHydrated, setViewMode } = useProjectsStore(); - - return ( -
-
-
- - - - - - Home - - - - - - - All projects - - - - - -
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( - - ))} -
-
- -
- - -
-
- -
- ); -} - -const SORT_LABELS: Record = { - 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 ( -
-
- - -
- - - - - - -
- -
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( - - ))} -
-
- {selectedProjectCount > 0 ? : null} -
- ); -} - -function SearchBar({ - className, - collapsed, -}: { - className?: string; - collapsed?: boolean; -}) { - const { searchQuery, setSearchQuery } = useProjectsStore(); - - return ( - <> - {collapsed ? ( -
- -
- ) : ( -
-
- )} - - ); -} - -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 void> = { - duplicate: handleDuplicate, - delete: handleDeleteClick, - }; - - return ( - <> -
-
- {PROJECT_ACTIONS.map((action) => ( - - ))} -
- - - - - - - {PROJECT_ACTIONS.map((action) => ( - - - {action.label} - - ))} - - -
- - - - ); -} - -function SortDropdown({ children }: { children: React.ReactNode }) { - const { sortKey, setSortKey } = useProjectsStore(); - - return ( - - {children} - - setSortKey({ sortKey: "createdAt" })} - > - Created - - setSortKey({ sortKey: "updatedAt" })} - > - Modified - - setSortKey({ sortKey: "name" })} - > - Name - - setSortKey({ sortKey: "duration" })} - > - Duration - - - - ); -} - -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 ( - - ); -} - -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 = ( - -
-
- {project.thumbnail ? ( - Project thumbnail - ) : ( -
- -
- )} -
- - {durationLabel && ( -
- {durationLabel} -
- )} -
- - -

- {project.name} -

-
- - Created {formatDate({ date: project.createdAt })} -
-
-
- ); - - const listRowContent = ( -
-
- {project.thumbnail ? ( - Project thumbnail - ) : ( -
- -
- )} -
- -

- {project.name} -

- - - {durationLabel ?? "—"} - - - - {formatDate({ date: project.createdAt })} - -
- ); - - const listContent = ( -
- event.preventDefault()} - onClick={(event) => { - handleCheckboxChange({ - checked: !isSelected, - shiftKey: event.shiftKey, - }); - }} - onCheckedChange={() => {}} - className="size-5 shrink-0" - /> - - - {listRowContent} - - - {!isMultiSelect && ( - - )} -
- ); - - return ( - <> - - -
- {isGridView ? ( - <> - - {gridContent} - - - 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 && ( - - )} - - ) : ( - listContent - )} -
-
- -
- - { - await renameProject({ editor, id: project.id, name: newName }); - setIsRenameDialogOpen(false); - }} - /> - - - - - - ); -} - -function ProjectContextMenuContent({ - onRenameClick, - onDuplicateClick, - onDeleteClick, - onInfoClick, -}: { - onRenameClick: () => void; - onDuplicateClick: () => void; - onDeleteClick: () => void; - onInfoClick: () => void; -}) { - return ( - - } - onClick={onRenameClick} - > - Rename - - } - onClick={onDuplicateClick} - > - Duplicate - - } - onClick={onInfoClick} - > - Info - - - } - onClick={onDeleteClick} - > - Delete - - - ); -} - -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; - }) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const handleMenuKeyDown = ({ - event, - }: { - event: KeyboardEvent; - }) => { - 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 ( - - - - - - - - Rename - - - - Duplicate - - - - Info - - - - Delete - - - - ); -} - -function ProjectsSkeleton() { - const skeletonIds = Array.from( - { length: 24 }, - (_, index) => `skeleton-${index}`, - ); - - return ( -
- {skeletonIds.map((skeletonId) => ( - -
-
- -
-
- - -
- - -
-
-
- ))} -
- ); -} - -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 ( -
-
- -
-

No results found

-

- Your search for "{searchQuery}" did not return any results. -

-
-
- -
- ); - } - - return ( -
-
-
- -
-

No projects yet

-

- Start creating your first project. Import media, edit, and export your - videos. All privately. -

-
- -
- ); -} +"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 ( +
+ + + + p.id)} /> +
+ {isLoading || !isInitialized ? ( + + ) : projectsToDisplay.length === 0 ? ( + + ) : ( +
+ {projectsToDisplay.map((project) => ( + p.id)} + /> + ))} +
+ )} +
+
+ ); +} + +function ProjectsHeader() { + const { viewMode, isHydrated, setViewMode } = useProjectsStore(); + + return ( +
+
+
+ + + + + + Home + + + + + + + All projects + + + + + +
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + + ))} +
+
+ +
+ + +
+
+ +
+ ); +} + +const SORT_LABELS: Record = { + 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 ( +
+
+ + +
+ + + + + + +
+ +
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + + ))} +
+
+ {selectedProjectCount > 0 ? : null} +
+ ); +} + +function SearchBar({ + className, + collapsed, +}: { + className?: string; + collapsed?: boolean; +}) { + const { searchQuery, setSearchQuery } = useProjectsStore(); + + return ( + <> + {collapsed ? ( +
+ +
+ ) : ( +
+
+ )} + + ); +} + +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 void> = { + duplicate: handleDuplicate, + delete: handleDeleteClick, + }; + + return ( + <> +
+
+ {PROJECT_ACTIONS.map((action) => ( + + ))} +
+ + + + + + + {PROJECT_ACTIONS.map((action) => ( + + + {action.label} + + ))} + + +
+ + + + ); +} + +function SortDropdown({ children }: { children: React.ReactNode }) { + const { sortKey, setSortKey } = useProjectsStore(); + + return ( + + {children} + + setSortKey({ sortKey: "createdAt" })} + > + Created + + setSortKey({ sortKey: "updatedAt" })} + > + Modified + + setSortKey({ sortKey: "name" })} + > + Name + + setSortKey({ sortKey: "duration" })} + > + Duration + + + + ); +} + +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 ( + + ); +} + +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 = ( + +
+
+ {project.thumbnail ? ( + Project thumbnail + ) : ( +
+ +
+ )} +
+ + {durationLabel && ( +
+ {durationLabel} +
+ )} +
+ + +

+ {project.name} +

+
+ + Created {formatDate({ date: project.createdAt })} +
+
+
+ ); + + const listRowContent = ( +
+
+ {project.thumbnail ? ( + Project thumbnail + ) : ( +
+ +
+ )} +
+ +

+ {project.name} +

+ + + {durationLabel ?? "—"} + + + + {formatDate({ date: project.createdAt })} + +
+ ); + + const listContent = ( +
+ event.preventDefault()} + onClick={(event) => { + handleCheckboxChange({ + checked: !isSelected, + shiftKey: event.shiftKey, + }); + }} + onCheckedChange={() => {}} + className="size-5 shrink-0" + /> + + + {listRowContent} + + + {!isMultiSelect && ( + + )} +
+ ); + + return ( + <> + + +
+ {isGridView ? ( + <> + + {gridContent} + + + 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 && ( + + )} + + ) : ( + listContent + )} +
+
+ +
+ + { + await renameProject({ editor, id: project.id, name: newName }); + setIsRenameDialogOpen(false); + }} + /> + + + + + + ); +} + +function ProjectContextMenuContent({ + onRenameClick, + onDuplicateClick, + onDeleteClick, + onInfoClick, +}: { + onRenameClick: () => void; + onDuplicateClick: () => void; + onDeleteClick: () => void; + onInfoClick: () => void; +}) { + return ( + + } + onClick={onRenameClick} + > + Rename + + } + onClick={onDuplicateClick} + > + Duplicate + + } + onClick={onInfoClick} + > + Info + + + } + onClick={onDeleteClick} + > + Delete + + + ); +} + +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; + }) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleMenuKeyDown = ({ + event, + }: { + event: KeyboardEvent; + }) => { + 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 ( + + + + + + + + Rename + + + + Duplicate + + + + Info + + + + Delete + + + + ); +} + +function ProjectsSkeleton() { + const skeletonIds = Array.from( + { length: 24 }, + (_, index) => `skeleton-${index}`, + ); + + return ( +
+ {skeletonIds.map((skeletonId) => ( + +
+
+ +
+
+ + +
+ + +
+
+
+ ))} +
+ ); +} + +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 ( +
+
+ +
+

No results found

+

+ Your search for "{searchQuery}" did not return any results. +

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

No projects yet

+

+ Start creating your first project. Import media, edit, and export your + videos. All privately. +

+
+ +
+ ); +} 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 = { - "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("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 ( - setView(v as SettingsView)}> - - Project info - Background - - - } - > - {view === "project-info" && ( -
-
- - Name - - {activeProject.metadata.name} - - -
-
- - Frame rate - - -
-
- - Aspect ratio - - - {presetItems.map((preset) => ( - } - isSelected={selectedPresetId === preset.id} - onClick={() => { - selectPresetCanvasSize({ - canvasSize: preset.canvasSize, - }); - }} - /> - ))} -
- } - isSelected={isCustomSelected} - onClick={selectCustomCanvasSize} - uiOptions={ -
- - -
- } - /> -
-
-
-
- )} - {view === "background" && } -
- ); -} - -function AspectRatioItem({ - label, - previewIcon, - isSelected, - onClick, - uiOptions, -}: { - label: string; - previewIcon: React.ReactNode; - isSelected: boolean; - onClick: () => void; - uiOptions?: React.ReactNode; -}) { - return ( - - ); -} - -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 ( -
- ); -} +"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 = { + "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("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 ( + setView(v as SettingsView)}> + + Project info + Background + + + } + > + {view === "project-info" && ( +
+
+ + Name + + {activeProject.metadata.name} + + +
+
+ + Frame rate + + +
+
+ + Aspect ratio + + + {presetItems.map((preset) => ( + } + isSelected={selectedPresetId === preset.id} + onClick={() => { + selectPresetCanvasSize({ + canvasSize: preset.canvasSize, + }); + }} + /> + ))} +
+ } + isSelected={isCustomSelected} + onClick={selectCustomCanvasSize} + uiOptions={ +
+ + +
+ } + /> +
+
+
+
+ )} + {view === "background" && } +
+ ); +} + +function AspectRatioItem({ + label, + previewIcon, + isSelected, + onClick, + uiOptions, +}: { + label: string; + previewIcon: React.ReactNode; + isSelected: boolean; + onClick: () => void; + uiOptions?: React.ReactNode; +}) { + return ( + + ); +} + +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 ( +
+ ); +} 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: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildBlendingTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "blending", - label: "Blending", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildAudioTab({ - element, -}: { - element: AudioElement | VideoElement; -}): PropertiesTabDef { - return { - id: "audio", - label: "Audio", - icon: , - content: ({ trackId }) => , - }; -} - -function buildSpeedTab({ - element, -}: { - element: RetimableElement; -}): PropertiesTabDef { - return { - id: "speed", - label: "Speed", - icon: , - content: ({ trackId }) => , - }; -} - -function buildMasksTab({ - element, -}: { - element: MaskableElement; -}): PropertiesTabDef { - return { - id: "masks", - label: "Masks", - icon: , - content: ({ trackId }) => , - }; -} - -function buildClipEffectsTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { - return { - id: "text", - label: "Text", - icon: , - content: ({ trackId }) => , - }; -} - -function buildGraphicTab({ - element, -}: { - element: GraphicElement; -}): PropertiesTabDef { - return { - id: "graphic", - label: "Graphic", - icon: , - content: ({ trackId }) => , - }; -} - -function buildStandaloneEffectTab({ - element, -}: { - element: EffectElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: , - content: ({ 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: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildBlendingTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "blending", + label: "Blending", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildAudioTab({ + element, +}: { + element: AudioElement | VideoElement; +}): PropertiesTabDef { + return { + id: "audio", + label: "Audio", + icon: , + content: ({ trackId }) => , + }; +} + +function buildSpeedTab({ + element, +}: { + element: RetimableElement; +}): PropertiesTabDef { + return { + id: "speed", + label: "Speed", + icon: , + content: ({ trackId }) => , + }; +} + +function buildMasksTab({ + element, +}: { + element: MaskableElement; +}): PropertiesTabDef { + return { + id: "masks", + label: "Masks", + icon: , + content: ({ trackId }) => , + }; +} + +function buildClipEffectsTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { + return { + id: "text", + label: "Text", + icon: , + content: ({ trackId }) => , + }; +} + +function buildGraphicTab({ + element, +}: { + element: GraphicElement; +}): PropertiesTabDef { + return { + id: "graphic", + label: "Graphic", + icon: , + content: ({ trackId }) => , + }; +} + +function buildStandaloneEffectTab({ + element, +}: { + element: EffectElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ 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 ( -
- - Blending - - -
- - } - > - - } - 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" - /> - - - - -
-
-
- ); -} +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 ( +
+ + Blending + + +
+ + } + > + + } + 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" + /> + + + + +
+
+
+ ); +} 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 ( -
- - - - -
- ); -} - -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 ( -
- - Content - - -