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:
85
package-lock.json
generated
85
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal file
184
src/app/(dashboard)/projects/[id]/project-settings-menu.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
143
src/components/project/delete-project-dialog.tsx
Normal file
143
src/components/project/delete-project-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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
23
src/hooks/use-toast.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user