From 8e1ec041de13e3313d0ed8d377b092dc5485a6f7 Mon Sep 17 00:00:00 2001 From: christiankrag Date: Mon, 15 Dec 2025 14:37:43 +0100 Subject: [PATCH] Add cascade delete for projects with Gitea repository cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add deleteGiteaRepo server action to remove repos via Gitea API - Add deleteProject function with full cascade: - Deletes Gitea repository if linked - Removes all agent_runs, messages, backlog items - Removes project phases, activities, recommendations - Finally removes the project itself - Add ProjectSettingsMenu with delete confirmation dialog - Add use-toast hook for notifications using sonner - Add shadcn alert-dialog component - Restore brand button variant after shadcn update 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 85 ++++++++ package.json | 1 + src/app/(dashboard)/projects/[id]/page.tsx | 12 +- .../projects/[id]/project-settings-menu.tsx | 184 ++++++++++++++++++ src/app/(dashboard)/projects/new/actions.ts | 31 +++ .../project/delete-project-dialog.tsx | 143 ++++++++++++++ src/components/ui/alert-dialog.tsx | 157 +++++++++++++++ src/components/ui/button.tsx | 17 +- src/hooks/use-toast.ts | 23 +++ src/lib/supabase/projects.ts | 111 +++++++++++ 10 files changed, 748 insertions(+), 16 deletions(-) create mode 100644 src/app/(dashboard)/projects/[id]/project-settings-menu.tsx create mode 100644 src/components/project/delete-project-dialog.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/hooks/use-toast.ts diff --git a/package-lock.json b/package-lock.json index 4671801..b350b35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mylder-frontend", "version": "0.1.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -1292,6 +1293,90 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", diff --git a/package.json b/package.json index 2798fc7..2a584a1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/app/(dashboard)/projects/[id]/page.tsx b/src/app/(dashboard)/projects/[id]/page.tsx index 957ca7a..de8fbdf 100644 --- a/src/app/(dashboard)/projects/[id]/page.tsx +++ b/src/app/(dashboard)/projects/[id]/page.tsx @@ -2,10 +2,10 @@ import { createClient } from '@/lib/supabase/server' import { notFound } from 'next/navigation' import { Badge } from '@/components/ui/badge' import Link from 'next/link' -import { ArrowLeft, Settings } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { ArrowLeft } from 'lucide-react' import type { Database } from '@/types/database' import { ProjectView } from './project-view' +import { ProjectSettingsMenu } from './project-settings-menu' type Project = Database['public']['Tables']['projects']['Row'] type Message = Database['public']['Tables']['messages']['Row'] @@ -51,9 +51,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st {project.status} - + diff --git a/src/app/(dashboard)/projects/[id]/project-settings-menu.tsx b/src/app/(dashboard)/projects/[id]/project-settings-menu.tsx new file mode 100644 index 0000000..251570c --- /dev/null +++ b/src/app/(dashboard)/projects/[id]/project-settings-menu.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { deleteProject } from '@/lib/supabase/projects' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Input } from '@/components/ui/input' +import { Settings, Trash2, ExternalLink, Loader2, AlertTriangle, GitBranch } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' + +interface ProjectSettingsMenuProps { + projectId: string + projectName: string + hasGiteaRepo: boolean +} + +export function ProjectSettingsMenu({ + projectId, + projectName, + hasGiteaRepo, +}: ProjectSettingsMenuProps) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [confirmText, setConfirmText] = useState('') + const [deleting, setDeleting] = useState(false) + const router = useRouter() + const { toast } = useToast() + + const canDelete = confirmText === projectName + + const handleDelete = async () => { + if (!canDelete) return + + setDeleting(true) + try { + const result = await deleteProject(projectId) + + if (result.success) { + toast({ + title: 'Project deleted', + description: hasGiteaRepo + ? 'Project and associated repository have been deleted.' + : 'Project has been deleted.', + }) + router.push('/projects') + router.refresh() + } else { + toast({ + title: 'Failed to delete project', + description: result.error || 'An error occurred', + variant: 'destructive', + }) + } + } catch { + toast({ + title: 'Error', + description: 'Failed to delete project', + variant: 'destructive', + }) + } finally { + setDeleting(false) + setDeleteDialogOpen(false) + setConfirmText('') + } + } + + return ( + <> + + + + + + {hasGiteaRepo && ( + <> + + + + View Repository + + + + + + )} + setDeleteDialogOpen(true)} + > + + Delete Project + + + + + + + + + + Delete Project + + +
+

+ This action cannot be undone. This will permanently delete the project + {projectName} and all + associated data including: +

+
    +
  • All messages and chat history
  • +
  • All agent runs and proposed changes
  • +
  • All backlog items and activities
  • +
  • All phase tracking data
  • + {hasGiteaRepo && ( +
  • + The Gitea repository and all its code +
  • + )} +
+
+

+ Type {projectName} to confirm: +

+ setConfirmText(e.target.value)} + placeholder="Project name" + className="font-mono" + /> +
+
+
+
+ + setConfirmText('')}> + Cancel + + + {deleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete Project + + )} + + +
+
+ + ) +} diff --git a/src/app/(dashboard)/projects/new/actions.ts b/src/app/(dashboard)/projects/new/actions.ts index c02ac8f..c2ca558 100644 --- a/src/app/(dashboard)/projects/new/actions.ts +++ b/src/app/(dashboard)/projects/new/actions.ts @@ -89,3 +89,34 @@ export async function createGiteaRepo(slug: string, description?: string): Promi export async function getGiteaRepoUrl(slug: string): Promise { return `${GITEA_URL}/${GITEA_OWNER}/${slug}` } + +interface DeleteRepoResult { + success: boolean + error?: string +} + +export async function deleteGiteaRepo(repoName: string): Promise { + try { + // Check if repo exists first + const checkResponse = await fetch(`${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${repoName}`, { + headers: { 'Authorization': `token ${GITEA_TOKEN}` }, + }) + + if (!checkResponse.ok) { + // Repo doesn't exist, consider it a success + return { success: true } + } + + // Delete the repository + await giteaRequest(`/repos/${GITEA_OWNER}/${repoName}`, { + method: 'DELETE', + }) + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete repository', + } + } +} diff --git a/src/components/project/delete-project-dialog.tsx b/src/components/project/delete-project-dialog.tsx new file mode 100644 index 0000000..4ec7e7a --- /dev/null +++ b/src/components/project/delete-project-dialog.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { deleteProject } from '@/lib/supabase/projects' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Trash2, Loader2, AlertTriangle } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' + +interface DeleteProjectDialogProps { + projectId: string + projectName: string + hasGiteaRepo: boolean +} + +export function DeleteProjectDialog({ + projectId, + projectName, + hasGiteaRepo, +}: DeleteProjectDialogProps) { + const [open, setOpen] = useState(false) + const [confirmText, setConfirmText] = useState('') + const [deleting, setDeleting] = useState(false) + const router = useRouter() + const { toast } = useToast() + + const canDelete = confirmText === projectName + + const handleDelete = async () => { + if (!canDelete) return + + setDeleting(true) + try { + const result = await deleteProject(projectId) + + if (result.success) { + toast({ + title: 'Project deleted', + description: hasGiteaRepo + ? 'Project and associated repository have been deleted.' + : 'Project has been deleted.', + }) + router.push('/projects') + router.refresh() + } else { + toast({ + title: 'Failed to delete project', + description: result.error || 'An error occurred', + variant: 'destructive', + }) + } + } catch { + toast({ + title: 'Error', + description: 'Failed to delete project', + variant: 'destructive', + }) + } finally { + setDeleting(false) + setOpen(false) + } + } + + return ( + + + + + + + + + Delete Project + + +

+ This action cannot be undone. This will permanently delete the project + {projectName} and all + associated data including: +

+
    +
  • All messages and chat history
  • +
  • All agent runs and proposed changes
  • +
  • All backlog items and activities
  • +
  • All phase tracking data
  • + {hasGiteaRepo && ( +
  • + The Gitea repository and all its code +
  • + )} +
+
+

+ Type {projectName} to confirm: +

+ setConfirmText(e.target.value)} + placeholder="Project name" + className="font-mono" + /> +
+
+
+ + Cancel + + {deleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete Project + + )} + + +
+
+ ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e708360..50fc018 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -10,24 +10,17 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", - brand: - "bg-brand text-brand-foreground hover:bg-brand/90 shadow-brand hover:shadow-brand-lg transition-shadow", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - success: - "bg-success text-success-foreground hover:bg-success/90", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - "outline-brand": - "border-brand/30 bg-brand/5 text-brand hover:bg-brand/10 hover:border-brand/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - "ghost-brand": - "text-brand hover:bg-brand/10 hover:text-brand", link: "text-primary underline-offset-4 hover:underline", - "link-brand": "text-brand underline-offset-4 hover:underline", + brand: + "bg-brand text-white hover:bg-brand/90 focus-visible:ring-brand/20", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", @@ -47,8 +40,8 @@ const buttonVariants = cva( function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & @@ -60,6 +53,8 @@ function Button({ return ( diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..7767e2b --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,23 @@ +import { toast as sonnerToast } from 'sonner' + +interface ToastProps { + title?: string + description?: string + variant?: 'default' | 'destructive' +} + +export function useToast() { + const toast = ({ title, description, variant }: ToastProps) => { + if (variant === 'destructive') { + sonnerToast.error(title, { + description, + }) + } else { + sonnerToast.success(title, { + description, + }) + } + } + + return { toast } +} diff --git a/src/lib/supabase/projects.ts b/src/lib/supabase/projects.ts index 81fdb17..5f8d134 100644 --- a/src/lib/supabase/projects.ts +++ b/src/lib/supabase/projects.ts @@ -1,4 +1,5 @@ import { createClient } from '@/lib/supabase/client' +import { deleteGiteaRepo } from '@/app/(dashboard)/projects/new/actions' import type { DesignPhase, PhaseStatus, @@ -317,3 +318,113 @@ export async function getProjectActivities(projectId: string, limit = 20) { created_at: string }> } + +// Delete project with cascade (includes Gitea repo deletion) +export async function deleteProject(projectId: string): Promise<{ success: boolean; error?: string }> { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + try { + // First, get the project to check ownership and get gitea_repo + const { data: projectData, error: fetchError } = await supabase + .from('projects') + .select('id, user_id, gitea_repo, name') + .eq('id', projectId) + .single() + + if (fetchError || !projectData) { + return { success: false, error: 'Project not found' } + } + + const project = projectData as unknown as { + id: string + user_id: string + gitea_repo: string | null + name: string + } + + // Verify ownership + if (project.user_id !== user.id) { + return { success: false, error: 'Not authorized to delete this project' } + } + + // Extract repo name from gitea_repo URL if it exists + // Format: https://gitea.mylder.io/admin/repo-name + if (project.gitea_repo) { + const repoMatch = project.gitea_repo.match(/\/([^/]+)$/) + const repoName = repoMatch ? repoMatch[1] : null + + if (repoName) { + const giteaResult = await deleteGiteaRepo(repoName) + if (!giteaResult.success) { + console.error('Failed to delete Gitea repo:', giteaResult.error) + // Continue with database deletion even if Gitea fails + } + } + } + + // Delete related data in order (due to foreign key constraints) + // Delete agent_runs + await supabase + .from('agent_runs') + .delete() + .eq('project_id', projectId) + + // Delete messages + await supabase + .from('messages') + .delete() + .eq('project_id', projectId) + + // Delete backlog items + await supabase + .from('backlog_items') + .delete() + .eq('project_id', projectId) + + // Delete project activities + await supabase + .from('project_activities') + .delete() + .eq('project_id', projectId) + + // Delete AI recommendations + await supabase + .from('ai_recommendations') + .delete() + .eq('project_id', projectId) + + // Delete project phases + await supabase + .from('project_phases') + .delete() + .eq('project_id', projectId) + + // Delete health snapshots + await supabase + .from('project_health_snapshots') + .delete() + .eq('project_id', projectId) + + // Finally, delete the project itself + const { error: deleteError } = await supabase + .from('projects') + .delete() + .eq('id', projectId) + + if (deleteError) { + return { success: false, error: deleteError.message } + } + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete project' + } + } +}