Add cascade delete for projects with Gitea repository cleanup

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 14:37:43 +01:00
parent 0ea463f1fa
commit 8e1ec041de
10 changed files with 748 additions and 16 deletions

85
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "mylder-frontend", "name": "mylder-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -1292,6 +1293,90 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "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": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",

View File

@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",

View File

@@ -2,10 +2,10 @@ import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, Settings } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { Database } from '@/types/database' import type { Database } from '@/types/database'
import { ProjectView } from './project-view' import { ProjectView } from './project-view'
import { ProjectSettingsMenu } from './project-settings-menu'
type Project = Database['public']['Tables']['projects']['Row'] type Project = Database['public']['Tables']['projects']['Row']
type Message = Database['public']['Tables']['messages']['Row'] type Message = Database['public']['Tables']['messages']['Row']
@@ -51,9 +51,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
{project.status} {project.status}
</Badge> </Badge>
</div> </div>
<Button variant="ghost" size="icon" className="h-8 w-8"> <ProjectSettingsMenu
<Settings className="h-4 w-4" /> projectId={id}
</Button> projectName={project.name}
hasGiteaRepo={!!project.gitea_repo}
/>
</div> </div>
</div> </div>

View File

@@ -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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{hasGiteaRepo && (
<>
<DropdownMenuItem asChild>
<a
href={`https://gitea.mylder.io/admin/${projectName.toLowerCase().replace(/\s+/g, '-')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<GitBranch className="h-4 w-4" />
View Repository
<ExternalLink className="h-3 w-3 ml-auto" />
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
Delete Project
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
This action cannot be undone. This will permanently delete the project
<strong className="text-foreground"> {projectName}</strong> and all
associated data including:
</p>
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
<li>All messages and chat history</li>
<li>All agent runs and proposed changes</li>
<li>All backlog items and activities</li>
<li>All phase tracking data</li>
{hasGiteaRepo && (
<li className="text-destructive font-medium">
The Gitea repository and all its code
</li>
)}
</ul>
<div className="pt-2">
<p className="text-sm mb-2">
Type <strong className="text-foreground">{projectName}</strong> to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Project name"
className="font-mono"
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting} onClick={() => setConfirmText('')}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!canDelete || deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -89,3 +89,34 @@ export async function createGiteaRepo(slug: string, description?: string): Promi
export async function getGiteaRepoUrl(slug: string): Promise<string> { export async function getGiteaRepoUrl(slug: string): Promise<string> {
return `${GITEA_URL}/${GITEA_OWNER}/${slug}` return `${GITEA_URL}/${GITEA_OWNER}/${slug}`
} }
interface DeleteRepoResult {
success: boolean
error?: string
}
export async function deleteGiteaRepo(repoName: string): Promise<DeleteRepoResult> {
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<void>(`/repos/${GITEA_OWNER}/${repoName}`, {
method: 'DELETE',
})
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete repository',
}
}
}

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="gap-2">
<Trash2 className="w-4 h-4" />
Delete Project
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
Delete Project
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This action cannot be undone. This will permanently delete the project
<strong className="text-foreground"> {projectName}</strong> and all
associated data including:
</p>
<ul className="list-disc list-inside text-sm space-y-1 text-muted-foreground">
<li>All messages and chat history</li>
<li>All agent runs and proposed changes</li>
<li>All backlog items and activities</li>
<li>All phase tracking data</li>
{hasGiteaRepo && (
<li className="text-destructive font-medium">
The Gitea repository and all its code
</li>
)}
</ul>
<div className="pt-2">
<p className="text-sm mb-2">
Type <strong className="text-foreground">{projectName}</strong> to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Project name"
className="font-mono"
/>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!canDelete || deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -10,24 +10,17 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", 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: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "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: 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", "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: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "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: "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: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -47,8 +40,8 @@ const buttonVariants = cva(
function Button({ function Button({
className, className,
variant, variant = "default",
size, size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
@@ -60,6 +53,8 @@ function Button({
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />

23
src/hooks/use-toast.ts Normal file
View File

@@ -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 }
}

View File

@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { deleteGiteaRepo } from '@/app/(dashboard)/projects/new/actions'
import type { import type {
DesignPhase, DesignPhase,
PhaseStatus, PhaseStatus,
@@ -317,3 +318,113 @@ export async function getProjectActivities(projectId: string, limit = 20) {
created_at: string 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'
}
}
}