diff options
Diffstat (limited to 'apps/web/src/app')
| -rw-r--r-- | apps/web/src/app/api/sounds/search/route.ts | 560 | ||||
| -rw-r--r-- | apps/web/src/app/layout.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 2030 |
3 files changed, 1296 insertions, 1296 deletions
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> + ); +} |
