1016 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|