OpenCut/apps/web/src/app/projects/page.tsx

1016 lines
26 KiB
TypeScript

"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>
);
}