Skip to content

Commit 8eee711

Browse files
fait le syst de fork (nous on vas le nommée duplicate)
et d'abord genre
1 parent b384b54 commit 8eee711

File tree

3 files changed

+232
-34
lines changed

3 files changed

+232
-34
lines changed

src/app/(app)/projects/[id]/actions.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
'use server';
33

4-
import type { Project, ProjectMember, ProjectMemberRole, Task, TaskStatus, Tag, Document as ProjectDocumentType, Announcement as ProjectAnnouncement, UserGithubOAuthToken, GithubRepoContentItem, User } from '@/types';
4+
import type { Project, ProjectMember, ProjectMemberRole, Task, TaskStatus, Tag, Document as ProjectDocumentType, Announcement as ProjectAnnouncement, UserGithubOAuthToken, GithubRepoContentItem, User, DuplicateProjectFormState } from '@/types';
55
import {
66
getProjectByUuid as dbGetProjectByUuid,
77
getUserByUuid as dbGetUserByUuid,
@@ -39,6 +39,7 @@ import {
3939
updateProjectDiscordSettings as dbUpdateProjectDiscordSettings,
4040
deleteProject as dbDeleteProject,
4141
updateProjectWebhookDetails as dbUpdateProjectWebhookDetails,
42+
createProject as dbCreateProject,
4243
} from '@/lib/db';
4344
import { z } from 'zod';
4445
import { auth } from '@/lib/authEdge';
@@ -2229,3 +2230,111 @@ export async function setupGithubWebhookAction(
22292230
return { error: errorMessage };
22302231
}
22312232
}
2233+
2234+
const DuplicateProjectSchema = z.object({
2235+
originalProjectUuid: z.string().uuid(),
2236+
forkGithubRepo: z.enum(['on', 'off']).transform(val => val === 'on').optional(),
2237+
});
2238+
2239+
export async function duplicateProjectAction(
2240+
prevState: DuplicateProjectFormState,
2241+
formData: FormData
2242+
): Promise<DuplicateProjectFormState> {
2243+
const session = await auth();
2244+
if (!session?.user?.uuid) {
2245+
return { error: "Authentication required." };
2246+
}
2247+
const newOwnerUuid = session.user.uuid;
2248+
2249+
const validatedFields = DuplicateProjectSchema.safeParse({
2250+
originalProjectUuid: formData.get('originalProjectUuid'),
2251+
forkGithubRepo: formData.get('forkGithubRepo') || 'off',
2252+
});
2253+
2254+
if (!validatedFields.success) {
2255+
return { error: "Invalid input for duplication." };
2256+
}
2257+
2258+
const { originalProjectUuid, forkGithubRepo } = validatedFields.data;
2259+
2260+
try {
2261+
const originalProject = await dbGetProjectByUuid(originalProjectUuid);
2262+
if (!originalProject) {
2263+
return { error: "Original project not found." };
2264+
}
2265+
2266+
const memberRole = await dbGetProjectMemberRole(originalProjectUuid, newOwnerUuid);
2267+
if (!originalProject.isPrivate && !memberRole) {
2268+
// Allow duplication of public projects by non-members
2269+
} else if (!memberRole) {
2270+
return { error: "You do not have permission to duplicate this private project." };
2271+
}
2272+
2273+
const newProjectName = `Copy of ${originalProject.name}`;
2274+
const newProject = await dbCreateProject(newProjectName, originalProject.description, newOwnerUuid);
2275+
2276+
await dbUpdateProjectReadme(newProject.uuid, originalProject.readmeContent || '');
2277+
2278+
const originalTasks = await dbGetTasksForProject(originalProjectUuid);
2279+
for (const task of originalTasks) {
2280+
await dbCreateTask({
2281+
projectUuid: newProject.uuid,
2282+
title: task.title,
2283+
description: task.description,
2284+
todoListMarkdown: task.todoListMarkdown,
2285+
status: task.status,
2286+
assigneeUuid: null,
2287+
tagsString: task.tags.map(t => t.name).join(', '),
2288+
});
2289+
}
2290+
2291+
const originalDocs = await dbGetDocumentsForProject(originalProjectUuid);
2292+
for (const doc of originalDocs) {
2293+
await dbCreateDocument({
2294+
projectUuid: newProject.uuid,
2295+
title: doc.title,
2296+
content: doc.content,
2297+
fileType: doc.fileType,
2298+
createdByUuid: newOwnerUuid,
2299+
});
2300+
}
2301+
2302+
const originalAnnouncements = await dbGetProjectAnnouncements(originalProjectUuid);
2303+
for (const ann of originalAnnouncements) {
2304+
await dbCreateProjectAnnouncement({
2305+
projectUuid: newProject.uuid,
2306+
authorUuid: newOwnerUuid,
2307+
title: ann.title,
2308+
content: ann.content,
2309+
});
2310+
}
2311+
2312+
let finalProject = await getProjectByUuid(newProject.uuid);
2313+
if (!finalProject) throw new Error("Failed to fetch duplicated project after creation.");
2314+
2315+
if (forkGithubRepo && originalProject.githubRepoName) {
2316+
const newOwnerToken = await dbGetUserGithubOAuthToken(newOwnerUuid);
2317+
if (!newOwnerToken?.accessToken) {
2318+
return { error: "Your GitHub account is not connected. The project was duplicated in FlowUp, but the repository could not be forked.", duplicatedProject: finalProject };
2319+
}
2320+
2321+
try {
2322+
const octokit = new Octokit({ auth: newOwnerToken.accessToken });
2323+
const [owner, repo] = originalProject.githubRepoName.split('/');
2324+
const forkResponse = await octokit.rest.repos.createFork({ owner, repo });
2325+
2326+
const forkedRepo = forkResponse.data;
2327+
finalProject = await dbUpdateProjectGithubRepo(newProject.uuid, forkedRepo.html_url, forkedRepo.full_name);
2328+
2329+
} catch (githubError: any) {
2330+
return { error: `FlowUp project duplicated, but failed to fork GitHub repo: ${githubError.message}`, duplicatedProject: finalProject };
2331+
}
2332+
}
2333+
2334+
return { message: "Project duplicated successfully!", duplicatedProject: finalProject };
2335+
2336+
} catch (error: any) {
2337+
console.error("Error duplicating project:", error);
2338+
return { error: error.message || "An unexpected error occurred during duplication." };
2339+
}
2340+
}

src/app/(app)/projects/page.tsx

Lines changed: 116 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,36 @@
33

44
import { Button } from "@/components/ui/button";
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6-
import { PlusCircle, Search, Filter, FolderKanban, Flame } from "lucide-react";
6+
import { PlusCircle, Search, Filter, FolderKanban, Flame, MoreHorizontal, Copy, Link as LinkIcon, ChevronDown } from "lucide-react";
77
import Link from "next/link";
88
import { Input } from "@/components/ui/input";
9-
import { useEffect, useState, useCallback } from "react";
10-
import type { Project } from "@/types";
9+
import { useEffect, useState, useCallback, useActionState } from "react";
10+
import type { Project, DuplicateProjectFormState } from "@/types";
1111
import { useAuth } from "@/hooks/useAuth";
1212
import { Skeleton } from "@/components/ui/skeleton";
1313
import { useRouter } from "next/navigation";
1414
import { fetchProjectsAction } from "./actions";
15+
import { duplicateProjectAction } from "./[id]/actions";
1516
import { cn } from "@/lib/utils";
17+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
18+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
19+
import { Checkbox } from "@/components/ui/checkbox";
20+
import { Label } from "@/components/ui/label";
21+
import { useToast } from "@/hooks/use-toast";
22+
import { Loader2 } from "lucide-react";
1623

1724

1825
export default function ProjectsPage() {
1926
const { user, isLoading: authLoading } = useAuth();
2027
const router = useRouter();
28+
const { toast } = useToast();
2129
const [projects, setProjects] = useState<Project[]>([]);
2230
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
2331
const [searchTerm, setSearchTerm] = useState('');
32+
33+
const [projectToDuplicate, setProjectToDuplicate] = useState<Project | null>(null);
34+
const [duplicateFormState, duplicateFormAction, isDuplicating] = useActionState(duplicateProjectAction, { message: "", error: ""});
35+
2436

2537
const loadProjects = useCallback(async () => {
2638
if (user && !authLoading) {
@@ -45,6 +57,19 @@ export default function ProjectsPage() {
4557
loadProjects();
4658
}, [loadProjects]);
4759

60+
useEffect(() => {
61+
if (!isDuplicating && duplicateFormState) {
62+
if (duplicateFormState.message && !duplicateFormState.error) {
63+
toast({ title: "Success!", description: duplicateFormState.message });
64+
setProjectToDuplicate(null); // Close dialog
65+
loadProjects(); // Refresh the list
66+
}
67+
if (duplicateFormState.error) {
68+
toast({ variant: "destructive", title: "Duplication Error", description: duplicateFormState.error });
69+
}
70+
}
71+
}, [duplicateFormState, isDuplicating, toast, loadProjects]);
72+
4873
if (authLoading && isLoadingProjects) {
4974
return (
5075
<div className="space-y-6">
@@ -150,35 +175,94 @@ export default function ProjectsPage() {
150175
) : (
151176
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
152177
{filteredProjects.map((project) => (
153-
<Card
154-
key={project.uuid}
155-
className={cn(
156-
"hover:shadow-lg transition-shadow flex flex-col",
157-
project.isUrgent && "border-destructive ring-1 ring-destructive"
158-
)}
159-
>
160-
<CardHeader className="flex-grow">
161-
<div className="flex justify-between items-start">
162-
<CardTitle className="hover:text-primary">
163-
<Link href={`/projects/${project.uuid}`}>{project.name}</Link>
164-
</CardTitle>
165-
{project.isUrgent && <Flame className="h-5 w-5 text-destructive flex-shrink-0" />}
166-
</div>
167-
<CardDescription className="h-12 overflow-hidden text-ellipsis line-clamp-2">
168-
{project.description || "No description provided."}
169-
</CardDescription>
170-
</CardHeader>
171-
<CardContent>
172-
<div className="text-xs text-muted-foreground space-y-0.5">
173-
<p>Owner: <span className="font-medium text-foreground">{project.ownerUuid === user?.uuid ? 'You' : 'Other'}</span></p>
174-
<p>Updated: <span className="font-medium text-foreground">{new Date(project.updatedAt).toLocaleDateString()}</span></p>
175-
<p>Status: <span className={cn("font-medium", project.isPrivate ? "text-foreground" : "text-green-600")}>{project.isPrivate ? 'Private' : 'Public'}</span></p>
176-
</div>
177-
<Button variant="outline" size="sm" className="w-full mt-3" asChild>
178-
<Link href={`/projects/${project.uuid}`}>View Details</Link>
179-
</Button>
180-
</CardContent>
181-
</Card>
178+
<Dialog key={project.uuid} open={projectToDuplicate?.uuid === project.uuid} onOpenChange={(open) => !open && setProjectToDuplicate(null)}>
179+
<Card
180+
className={cn(
181+
"hover:shadow-lg transition-shadow flex flex-col",
182+
project.isUrgent && "border-destructive ring-1 ring-destructive"
183+
)}
184+
>
185+
<CardHeader className="flex-grow">
186+
<div className="flex justify-between items-start">
187+
<CardTitle className="hover:text-primary">
188+
<Link href={`/projects/${project.uuid}`}>{project.name}</Link>
189+
</CardTitle>
190+
{project.isUrgent && <Flame className="h-5 w-5 text-destructive flex-shrink-0" />}
191+
</div>
192+
<CardDescription className="h-12 overflow-hidden text-ellipsis line-clamp-2">
193+
{project.description || "No description provided."}
194+
</CardDescription>
195+
</CardHeader>
196+
<CardContent>
197+
<div className="text-xs text-muted-foreground space-y-0.5">
198+
<p>Owner: <span className="font-medium text-foreground">{project.ownerUuid === user?.uuid ? 'You' : 'Other'}</span></p>
199+
<p>Updated: <span className="font-medium text-foreground">{new Date(project.updatedAt).toLocaleDateString()}</span></p>
200+
<p>Status: <span className={cn("font-medium", project.isPrivate ? "text-foreground" : "text-green-600")}>{project.isPrivate ? 'Private' : 'Public'}</span></p>
201+
</div>
202+
<div className="mt-3">
203+
<DropdownMenu>
204+
<DropdownMenuTrigger asChild>
205+
<Button variant="outline" size="sm" className="w-full">
206+
Actions <ChevronDown className="ml-auto h-4 w-4" />
207+
</Button>
208+
</DropdownMenuTrigger>
209+
<DropdownMenuContent align="end" className="w-48">
210+
<DropdownMenuItem asChild>
211+
<Link href={`/projects/${project.uuid}`}>
212+
<FolderKanban className="mr-2 h-4 w-4"/>
213+
<span>View Details</span>
214+
</Link>
215+
</DropdownMenuItem>
216+
<DropdownMenuItem onSelect={() => navigator.clipboard.writeText(`${window.location.origin}/projects/${project.uuid}`)}>
217+
<LinkIcon className="mr-2 h-4 w-4"/>
218+
<span>Copy Link</span>
219+
</DropdownMenuItem>
220+
<DropdownMenuSeparator />
221+
<DialogTrigger asChild>
222+
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); setProjectToDuplicate(project); }}>
223+
<Copy className="mr-2 h-4 w-4"/>
224+
<span>Duplicate</span>
225+
</DropdownMenuItem>
226+
</DialogTrigger>
227+
</DropdownMenuContent>
228+
</DropdownMenu>
229+
</div>
230+
</CardContent>
231+
</Card>
232+
<DialogContent>
233+
<DialogHeader>
234+
<DialogTitle>Duplicate Project: {project.name}</DialogTitle>
235+
<DialogDescription>
236+
This will create a new project with a copy of all tasks, documents, and settings. Members and integrations will not be copied.
237+
</DialogDescription>
238+
</DialogHeader>
239+
<form action={duplicateFormAction}>
240+
<input type="hidden" name="originalProjectUuid" value={project.uuid} />
241+
<div className="space-y-4 my-4">
242+
{project.githubRepoUrl && (
243+
<div className="flex items-start space-x-3 rounded-md border p-3">
244+
<Checkbox id={`fork-repo-${project.uuid}`} name="forkGithubRepo" />
245+
<div className="grid gap-1.5 leading-none">
246+
<Label htmlFor={`fork-repo-${project.uuid}`}>
247+
Also fork the GitHub repository
248+
</Label>
249+
<p className="text-sm text-muted-foreground">
250+
This will fork <span className="font-mono bg-muted/50 px-1 py-0.5 rounded text-xs">{project.githubRepoName}</span> to your GitHub account. Requires a connected GitHub account.
251+
</p>
252+
</div>
253+
</div>
254+
)}
255+
</div>
256+
<DialogFooter>
257+
<DialogClose asChild><Button type="button" variant="ghost" disabled={isDuplicating}>Cancel</Button></DialogClose>
258+
<Button type="submit" disabled={isDuplicating}>
259+
{isDuplicating && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
260+
Duplicate Project
261+
</Button>
262+
</DialogFooter>
263+
</form>
264+
</DialogContent>
265+
</Dialog>
182266
))}
183267
</div>
184268
)}
@@ -187,4 +271,3 @@ export default function ProjectsPage() {
187271
</div>
188272
);
189273
}
190-

src/types/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,9 @@ export interface DiscordWebhookPayload {
181181
avatar_url?: string;
182182
embeds?: DiscordEmbed[];
183183
}
184+
185+
export interface DuplicateProjectFormState {
186+
message?: string;
187+
error?: string;
188+
duplicatedProject?: Project;
189+
}

0 commit comments

Comments
 (0)