1+
12'use client' ;
23
34import { Button } from "@/components/ui/button" ;
45import { Card , CardContent , CardDescription , CardHeader , CardTitle } from "@/components/ui/card" ;
5- import { PlusCircle , Search , Filter , FolderKanban , Flame , MoreHorizontal , Copy , Link as LinkIcon , ChevronDown , Github , Loader2 } from "lucide-react" ;
6+ import { PlusCircle , Search , Filter , FolderKanban , Flame , MoreHorizontal , Copy , Link as LinkIcon , ChevronDown , Github , Loader2 , X , Trash2 , EyeOff , Eye } from "lucide-react" ;
67import Link from "next/link" ;
78import { Input } from "@/components/ui/input" ;
89import { useEffect , useState , useCallback , useActionState , useTransition } from "react" ;
910import type { Project , DuplicateProjectFormState } from "@/types" ;
1011import { useAuth } from "@/hooks/useAuth" ;
1112import { Skeleton } from "@/components/ui/skeleton" ;
1213import { useRouter } from "next/navigation" ;
13- import { fetchProjectsAction , getLinkableGithubReposAction , createProjectFromRepoAction , importFlowUpProjectsAction } from "./actions" ;
14+ import { fetchProjectsAction , getLinkableGithubReposAction , createProjectFromRepoAction , importFlowUpProjectsAction , batchUpdateProjectsAction } from "./actions" ;
1415import type { LinkableGithubRepo } from "./actions" ;
1516import { duplicateProjectAction } from "./[id]/actions" ;
1617import { cn } from "@/lib/utils" ;
@@ -39,6 +40,9 @@ export default function ProjectsPage() {
3940 const [ linkableRepos , setLinkableRepos ] = useState < LinkableGithubRepo [ ] > ( [ ] ) ;
4041 const [ isLoadingRepos , setIsLoadingRepos ] = useState ( false ) ;
4142 const [ repoToImport , setRepoToImport ] = useState < LinkableGithubRepo | null > ( null ) ;
43+
44+ const [ selectedProjects , setSelectedProjects ] = useState < string [ ] > ( [ ] ) ;
45+ const [ isBatchProcessing , startBatchTransition ] = useTransition ( ) ;
4246
4347 const loadProjects = useCallback ( async ( ) => {
4448 if ( user && ! authLoading ) {
@@ -103,7 +107,6 @@ export default function ProjectsPage() {
103107 } ) ;
104108 } ;
105109
106-
107110 useEffect ( ( ) => {
108111 if ( ! isDuplicating && duplicateFormState ) {
109112 if ( duplicateFormState . message && ! duplicateFormState . error ) {
@@ -117,6 +120,36 @@ export default function ProjectsPage() {
117120 }
118121 } , [ duplicateFormState , isDuplicating , toast , loadProjects ] ) ;
119122
123+ const handleSelectProject = ( projectId : string , isSelected : boolean ) => {
124+ if ( isSelected ) {
125+ setSelectedProjects ( prev => [ ...prev , projectId ] ) ;
126+ } else {
127+ setSelectedProjects ( prev => prev . filter ( id => id !== projectId ) ) ;
128+ }
129+ } ;
130+
131+ const handleSelectAll = ( isSelected : boolean ) => {
132+ if ( isSelected ) {
133+ setSelectedProjects ( filteredProjects . map ( p => p . uuid ) ) ;
134+ } else {
135+ setSelectedProjects ( [ ] ) ;
136+ }
137+ } ;
138+
139+ const handleBatchAction = ( action : 'makePrivate' | 'makePublic' | 'delete' ) => {
140+ startBatchTransition ( async ( ) => {
141+ const result = await batchUpdateProjectsAction ( selectedProjects , action ) ;
142+ if ( result . successCount > 0 ) {
143+ toast ( { title : 'Batch Action Complete' , description : `${ result . successCount } projects updated successfully.` } ) ;
144+ }
145+ if ( result . errorCount > 0 ) {
146+ toast ( { variant : 'destructive' , title : 'Batch Action Failed' , description : `${ result . errorCount } projects failed to update. ${ result . errors . join ( ' ' ) } ` } ) ;
147+ }
148+ setSelectedProjects ( [ ] ) ;
149+ await loadProjects ( ) ;
150+ } ) ;
151+ } ;
152+
120153 if ( authLoading && isLoadingProjects ) {
121154 return (
122155 < div className = "space-y-6" >
@@ -154,6 +187,8 @@ export default function ProjectsPage() {
154187 project . name . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ||
155188 ( project . description && project . description . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) )
156189 ) ;
190+
191+ const allSelected = filteredProjects . length > 0 && selectedProjects . length === filteredProjects . length ;
157192
158193 return (
159194 < div className = "space-y-6" >
@@ -194,6 +229,19 @@ export default function ProjectsPage() {
194229 </ Button >
195230 </ div >
196231 </ div >
232+ { selectedProjects . length > 0 && (
233+ < div className = "mt-4 p-2 border rounded-lg bg-muted/50 flex flex-col sm:flex-row items-center justify-between gap-2" >
234+ < div className = "flex items-center gap-2" >
235+ < Checkbox id = "select-all" checked = { allSelected } onCheckedChange = { ( checked ) => handleSelectAll ( Boolean ( checked ) ) } />
236+ < Label htmlFor = "select-all" className = "text-sm font-medium" > { selectedProjects . length } selected</ Label >
237+ </ div >
238+ < div className = "flex items-center gap-2" >
239+ < Button size = "sm" variant = "outline" onClick = { ( ) => handleBatchAction ( 'makePublic' ) } disabled = { isBatchProcessing } > < Eye className = "mr-2 h-4 w-4" /> Make Public</ Button >
240+ < Button size = "sm" variant = "outline" onClick = { ( ) => handleBatchAction ( 'makePrivate' ) } disabled = { isBatchProcessing } > < EyeOff className = "mr-2 h-4 w-4" /> Make Private</ Button >
241+ < Button size = "sm" variant = "destructive" onClick = { ( ) => handleBatchAction ( 'delete' ) } disabled = { isBatchProcessing } > < Trash2 className = "mr-2 h-4 w-4" /> Delete</ Button >
242+ </ div >
243+ </ div >
244+ ) }
197245 </ CardHeader >
198246 < CardContent >
199247 { isLoadingProjects ? (
@@ -231,11 +279,19 @@ export default function ProjectsPage() {
231279 < Dialog key = { project . uuid } open = { projectToDuplicate ?. uuid === project . uuid } onOpenChange = { ( open ) => ! open && setProjectToDuplicate ( null ) } >
232280 < Card
233281 className = { cn (
234- "hover:shadow-lg transition-shadow flex flex-col" ,
235- project . isUrgent && "border-destructive ring-1 ring-destructive"
282+ "hover:shadow-lg transition-shadow flex flex-col relative" ,
283+ project . isUrgent && "border-destructive ring-1 ring-destructive" ,
284+ selectedProjects . includes ( project . uuid ) && "ring-2 ring-primary border-primary"
236285 ) }
237286 >
238- < CardHeader className = "flex-grow" >
287+ < div className = "absolute top-2 left-2 z-10" >
288+ < Checkbox
289+ id = { `select-${ project . uuid } ` }
290+ checked = { selectedProjects . includes ( project . uuid ) }
291+ onCheckedChange = { ( checked ) => handleSelectProject ( project . uuid , Boolean ( checked ) ) }
292+ />
293+ </ div >
294+ < CardHeader className = "flex-grow pt-8" >
239295 < div className = "flex justify-between items-start" >
240296 < CardTitle className = "hover:text-primary" >
241297 < Link href = { `/projects/${ project . uuid } ` } > { project . name } </ Link >
@@ -248,7 +304,7 @@ export default function ProjectsPage() {
248304 </ CardHeader >
249305 < CardContent >
250306 < div className = "text-xs text-muted-foreground space-y-0.5" >
251- < p > Owner: < span className = "font-medium text-foreground" > { project . ownerUuid === user ?. uuid ? 'You' : 'Other' } </ span > </ p >
307+ < p > Owner: < span className = "font-medium text-foreground" > { project . ownerUuid === user ?. uuid ? 'You' : project . ownerName || 'Other' } </ span > </ p >
252308 < p > Updated: < span className = "font-medium text-foreground" > { new Date ( project . updatedAt ) . toLocaleDateString ( ) } </ span > </ p >
253309 < p > Status: < span className = { cn ( "font-medium" , project . isPrivate ? "text-foreground" : "text-green-600" ) } > { project . isPrivate ? 'Private' : 'Public' } </ span > </ p >
254310 </ div >
0 commit comments