diff options
Diffstat (limited to 'apps/web/src/app/projects')
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 2030 | ||||
| -rw-r--r-- | apps/web/src/app/projects/store.ts | 270 |
2 files changed, 1150 insertions, 1150 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index f32ce2b9..8c5d9d04 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 "@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>
+ );
+}
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts index 2c79aa0a..71f89b4a 100644 --- a/apps/web/src/app/projects/store.ts +++ b/apps/web/src/app/projects/store.ts @@ -1,135 +1,135 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TProjectSortKey } from "@/lib/project/types"; - -export type ProjectsViewMode = "grid" | "list"; - -interface ProjectsState { - searchQuery: string; - sortKey: TProjectSortKey; - sortOrder: "asc" | "desc"; - viewMode: ProjectsViewMode; - selectedProjectIds: string[]; - lastSelectedProjectId: string | null; - isHydrated: boolean; - setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void; - setSearchQuery: ({ query }: { query: string }) => void; - setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void; - setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void; - toggleSortOrder: () => void; - setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void; - setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void; - clearSelectedProjects: () => void; - setProjectSelected: ({ - projectId, - isSelected, - }: { - projectId: string; - isSelected: boolean; - }) => void; - selectProjectRange: ({ - projectId, - allProjectIds, - }: { - projectId: string; - allProjectIds: string[]; - }) => void; -} - -const getNextSelectedProjectIds = ({ - selectedProjectIds, - projectId, - isSelected, -}: { - selectedProjectIds: string[]; - projectId: string; - isSelected: boolean; -}): string[] => { - const selectedProjectIdSet = new Set(selectedProjectIds); - - if (isSelected) { - selectedProjectIdSet.add(projectId); - return Array.from(selectedProjectIdSet); - } - - selectedProjectIdSet.delete(projectId); - return Array.from(selectedProjectIdSet); -}; - -export const useProjectsStore = create<ProjectsState>()( - persist( - (set) => ({ - searchQuery: "", - sortKey: "updatedAt", - sortOrder: "desc", - viewMode: "grid", - selectedProjectIds: [], - lastSelectedProjectId: null, - isHydrated: false, - setIsHydrated: ({ isHydrated }) => set({ isHydrated }), - setSearchQuery: ({ query }) => set({ searchQuery: query }), - setSortKey: ({ sortKey }) => set({ sortKey }), - setSortOrder: ({ sortOrder }) => set({ sortOrder }), - toggleSortOrder: () => - set((state) => ({ - sortOrder: state.sortOrder === "asc" ? "desc" : "asc", - })), - setViewMode: ({ viewMode }) => set({ viewMode }), - setSelectedProjects: ({ projectIds }) => - set({ selectedProjectIds: projectIds }), - clearSelectedProjects: () => - set({ selectedProjectIds: [], lastSelectedProjectId: null }), - setProjectSelected: ({ projectId, isSelected }) => - set((state) => ({ - selectedProjectIds: getNextSelectedProjectIds({ - selectedProjectIds: state.selectedProjectIds, - projectId, - isSelected, - }), - lastSelectedProjectId: isSelected - ? projectId - : state.lastSelectedProjectId, - })), - selectProjectRange: ({ projectId, allProjectIds }) => - set((state) => { - const anchorId = state.lastSelectedProjectId; - if (!anchorId) { - return { - selectedProjectIds: [projectId], - lastSelectedProjectId: projectId, - }; - } - - const anchorIndex = allProjectIds.indexOf(anchorId); - const targetIndex = allProjectIds.indexOf(projectId); - - if (anchorIndex === -1 || targetIndex === -1) { - return { - selectedProjectIds: [projectId], - lastSelectedProjectId: projectId, - }; - } - - const startIndex = Math.min(anchorIndex, targetIndex); - const endIndex = Math.max(anchorIndex, targetIndex); - const rangeIds = allProjectIds.slice(startIndex, endIndex + 1); - - const merged = new Set([...state.selectedProjectIds, ...rangeIds]); - return { - selectedProjectIds: Array.from(merged), - }; - }), - }), - { - name: "projects-view-mode", - partialize: (state) => ({ - viewMode: state.viewMode, - sortKey: state.sortKey, - sortOrder: state.sortOrder, - }), - onRehydrateStorage: () => (state) => { - state?.setIsHydrated({ isHydrated: true }); - }, - }, - ), -); +import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { TProjectSortKey } from "@/lib/project/types";
+
+export type ProjectsViewMode = "grid" | "list";
+
+interface ProjectsState {
+ searchQuery: string;
+ sortKey: TProjectSortKey;
+ sortOrder: "asc" | "desc";
+ viewMode: ProjectsViewMode;
+ selectedProjectIds: string[];
+ lastSelectedProjectId: string | null;
+ isHydrated: boolean;
+ setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
+ setSearchQuery: ({ query }: { query: string }) => void;
+ setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
+ setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
+ toggleSortOrder: () => void;
+ setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
+ setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
+ clearSelectedProjects: () => void;
+ setProjectSelected: ({
+ projectId,
+ isSelected,
+ }: {
+ projectId: string;
+ isSelected: boolean;
+ }) => void;
+ selectProjectRange: ({
+ projectId,
+ allProjectIds,
+ }: {
+ projectId: string;
+ allProjectIds: string[];
+ }) => void;
+}
+
+const getNextSelectedProjectIds = ({
+ selectedProjectIds,
+ projectId,
+ isSelected,
+}: {
+ selectedProjectIds: string[];
+ projectId: string;
+ isSelected: boolean;
+}): string[] => {
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+
+ if (isSelected) {
+ selectedProjectIdSet.add(projectId);
+ return Array.from(selectedProjectIdSet);
+ }
+
+ selectedProjectIdSet.delete(projectId);
+ return Array.from(selectedProjectIdSet);
+};
+
+export const useProjectsStore = create<ProjectsState>()(
+ persist(
+ (set) => ({
+ searchQuery: "",
+ sortKey: "updatedAt",
+ sortOrder: "desc",
+ viewMode: "grid",
+ selectedProjectIds: [],
+ lastSelectedProjectId: null,
+ isHydrated: false,
+ setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
+ setSearchQuery: ({ query }) => set({ searchQuery: query }),
+ setSortKey: ({ sortKey }) => set({ sortKey }),
+ setSortOrder: ({ sortOrder }) => set({ sortOrder }),
+ toggleSortOrder: () =>
+ set((state) => ({
+ sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
+ })),
+ setViewMode: ({ viewMode }) => set({ viewMode }),
+ setSelectedProjects: ({ projectIds }) =>
+ set({ selectedProjectIds: projectIds }),
+ clearSelectedProjects: () =>
+ set({ selectedProjectIds: [], lastSelectedProjectId: null }),
+ setProjectSelected: ({ projectId, isSelected }) =>
+ set((state) => ({
+ selectedProjectIds: getNextSelectedProjectIds({
+ selectedProjectIds: state.selectedProjectIds,
+ projectId,
+ isSelected,
+ }),
+ lastSelectedProjectId: isSelected
+ ? projectId
+ : state.lastSelectedProjectId,
+ })),
+ selectProjectRange: ({ projectId, allProjectIds }) =>
+ set((state) => {
+ const anchorId = state.lastSelectedProjectId;
+ if (!anchorId) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const anchorIndex = allProjectIds.indexOf(anchorId);
+ const targetIndex = allProjectIds.indexOf(projectId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const startIndex = Math.min(anchorIndex, targetIndex);
+ const endIndex = Math.max(anchorIndex, targetIndex);
+ const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
+
+ const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
+ return {
+ selectedProjectIds: Array.from(merged),
+ };
+ }),
+ }),
+ {
+ name: "projects-view-mode",
+ partialize: (state) => ({
+ viewMode: state.viewMode,
+ sortKey: state.sortKey,
+ sortOrder: state.sortOrder,
+ }),
+ onRehydrateStorage: () => (state) => {
+ state?.setIsHydrated({ isHydrated: true });
+ },
+ },
+ ),
+);
|
