diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2025-07-07 03:46:48 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-07 03:46:48 +0200 |
| commit | 25c9ffc131c3333da22502b257cc8d2eb6254316 (patch) | |
| tree | 3fd1df2ae2ac9694dc97c2dc4ef224484348e496 /apps/web | |
| parent | 85a93ce0907aaa30eaa6a7d8843b987329fd85dd (diff) | |
| parent | d3d5bbf51ae749862d7d8e965d4a2ab981095398 (diff) | |
| download | opencut-25c9ffc131c3333da22502b257cc8d2eb6254316.tar.gz | |
Merge pull request #164 from Pratiyankkumar/select-multiple-projects
feat: add multiple project selection and bulk delete functionality
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 476 |
1 files changed, 384 insertions, 92 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index c983894d..3d8612db 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { ChevronLeft, Plus, @@ -11,6 +12,8 @@ import { MoreHorizontal, Video, Loader2, + X, + Trash2, } from "lucide-react"; import { TProject } from "@/types/project"; import Image from "next/image"; @@ -27,9 +30,19 @@ import { DeleteProjectDialog } from "@/components/delete-project-dialog"; import { RenameProjectDialog } from "@/components/rename-project-dialog"; export default function ProjectsPage() { - const { createNewProject, savedProjects, isLoading, isInitialized } = - useProjectStore(); + const { + createNewProject, + savedProjects, + isLoading, + isInitialized, + deleteProject, + } = useProjectStore(); const router = useRouter(); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedProjects, setSelectedProjects] = useState<Set<string>>( + new Set() + ); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const handleCreateProject = async () => { const projectId = await createNewProject("New Project"); @@ -37,6 +50,43 @@ export default function ProjectsPage() { router.push(`/editor/${projectId}`); }; + const handleSelectProject = (projectId: string, checked: boolean) => { + const newSelected = new Set(selectedProjects); + if (checked) { + newSelected.add(projectId); + } else { + newSelected.delete(projectId); + } + setSelectedProjects(newSelected); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedProjects(new Set(savedProjects.map((p) => p.id))); + } else { + setSelectedProjects(new Set()); + } + }; + + const handleCancelSelection = () => { + setIsSelectionMode(false); + setSelectedProjects(new Set()); + }; + + const handleBulkDelete = async () => { + for (const projectId of selectedProjects) { + await deleteProject(projectId); + } + setSelectedProjects(new Set()); + setIsSelectionMode(false); + setIsBulkDeleteDialogOpen(false); + }; + + const allSelected = + savedProjects.length > 0 && selectedProjects.size === savedProjects.length; + const someSelected = + selectedProjects.size > 0 && selectedProjects.size < savedProjects.length; + return ( <div className="min-h-screen bg-background"> <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> @@ -48,7 +98,30 @@ export default function ProjectsPage() { <span className="text-sm font-medium">Back</span> </Link> <div className="block md:hidden"> - <CreateButton onClick={handleCreateProject} /> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleCancelSelection} + > + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <CreateButton onClick={handleCreateProject} /> + )} </div> </div> <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> @@ -60,13 +133,72 @@ export default function ProjectsPage() { <p className="text-muted-foreground"> {savedProjects.length}{" "} {savedProjects.length === 1 ? "project" : "projects"} + {isSelectionMode && selectedProjects.size > 0 && ( + <span className="ml-2 text-primary"> + • {selectedProjects.size} selected + </span> + )} </p> </div> <div className="hidden md:block"> - <CreateButton onClick={handleCreateProject} /> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={handleCancelSelection}> + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete Selected ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => setIsSelectionMode(true)} + disabled={savedProjects.length === 0} + > + Select Projects + </Button> + <CreateButton onClick={handleCreateProject} /> + </div> + )} </div> </div> + {isSelectionMode && savedProjects.length > 0 && ( + <div className="mb-6 p-4 bg-muted/30 rounded-lg border"> + <div className="flex items-center gap-3"> + <Checkbox + checked={allSelected} + ref={(el) => { + if (el) { + const checkboxElement = el.querySelector( + "input" + ) as HTMLInputElement; + if (checkboxElement) { + checkboxElement.indeterminate = someSelected; + } + } + }} + onCheckedChange={handleSelectAll} + /> + <span className="text-sm font-medium"> + {allSelected ? "Deselect All" : "Select All"} + </span> + <span className="text-sm text-muted-foreground"> + ({selectedProjects.size} of {savedProjects.length} selected) + </span> + </div> + </div> + )} + {isLoading || !isInitialized ? ( <div className="flex items-center justify-center py-16"> <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> @@ -76,16 +208,40 @@ export default function ProjectsPage() { ) : ( <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> {savedProjects.map((project) => ( - <ProjectCard key={project.id} project={project} /> + <ProjectCard + key={project.id} + project={project} + isSelectionMode={isSelectionMode} + isSelected={selectedProjects.has(project.id)} + onSelect={handleSelectProject} + /> ))} </div> )} </main> + + <DeleteProjectDialog + isOpen={isBulkDeleteDialogOpen} + onOpenChange={setIsBulkDeleteDialogOpen} + onConfirm={handleBulkDelete} + /> </div> ); } -function ProjectCard({ project }: { project: TProject }) { +interface ProjectCardProps { + project: TProject; + isSelectionMode?: boolean; + isSelected?: boolean; + onSelect?: (projectId: string, checked: boolean) => void; +} + +function ProjectCard({ + project, + isSelectionMode = false, + isSelected = false, + onSelect, +}: ProjectCardProps) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); @@ -114,108 +270,244 @@ function ProjectCard({ project }: { project: TProject }) { await duplicateProject(project.id); }; + const handleCardClick = (e: React.MouseEvent) => { + if (isSelectionMode) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; + return ( <> - <Link href={`/editor/${project.id}`} className="block group"> - <Card className="overflow-hidden bg-background border-none p-0"> - <div - className={`relative aspect-square bg-muted transition-opacity ${ - isDropdownOpen - ? "opacity-65" - : "opacity-100 group-hover:opacity-65" + {isSelectionMode ? ( + <div onClick={handleCardClick} className="block group cursor-pointer"> + <Card + className={`overflow-hidden bg-background border-none p-0 transition-all ${ + isSelectionMode && isSelected ? "ring-2 ring-primary" : "" }`} > - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - alt="Project thumbnail" - fill - className="object-cover" - /> - ) : ( - <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen + ? "opacity-65" + : "opacity-100 group-hover:opacity-65" + }`} + > + {/* Selection checkbox */} + {isSelectionMode && ( + <div className="absolute top-3 left-3 z-10"> + <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => + onSelect?.(project.id, checked as boolean) + } + onClick={(e) => e.stopPropagation()} + className="w-4 h-4" + /> + </div> </div> )} + + {/* Thumbnail preview or placeholder */} + <div className="absolute inset-0"> + {project.thumbnail ? ( + <Image + src={project.thumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> </div> - </div> - <CardContent className="px-0 pt-5 flex flex-col gap-1"> - <div className="flex items-start justify-between"> - <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> - {project.name} - </h3> - <DropdownMenu - open={isDropdownOpen} - onOpenChange={setIsDropdownOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="sm" - className={`size-6 p-0 transition-all shrink-0 ml-2 ${ - isDropdownOpen - ? "opacity-100" - : "opacity-0 group-hover:opacity-100" - }`} - onClick={(e) => e.preventDefault()} + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + {!isSelectionMode && ( + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} > - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + </div> + + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> + </div> + </CardContent> + </Card> + </div> + ) : ( + <Link href={`/editor/${project.id}`} className="block group"> + <Card + className={`overflow-hidden bg-background border-none p-0 transition-all ${ + isSelectionMode && isSelected ? "ring-2 ring-primary" : "" + }`} + > + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen + ? "opacity-65" + : "opacity-100 group-hover:opacity-65" + }`} + > + {/* Thumbnail preview or placeholder */} + <div className="absolute inset-0"> + {project.thumbnail ? ( + <Image + src={project.thumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> + </div> + + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} > - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - </DropdownMenuItem> - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={(e) => { + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { e.preventDefault(); e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); }} > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> - <span>Created {formatDate(project.createdAt)}</span> + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> </div> - </div> - </CardContent> - </Card> - </Link> + </CardContent> + </Card> + </Link> + )} <DeleteProjectDialog isOpen={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen} |
