Skip to content

Commit a86a4de

Browse files
committed
feat: add delete project dialog with typed confirmation
1 parent 130f8f6 commit a86a4de

1 file changed

Lines changed: 139 additions & 26 deletions

File tree

apps/studio/src/routes/projects.$projectId.index.tsx

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ import { Button } from '@/components/ui/button';
3333
import { Badge } from '@/components/ui/badge';
3434
import { Separator } from '@/components/ui/separator';
3535
import { Input } from '@/components/ui/input';
36+
import { Label } from '@/components/ui/label';
37+
import {
38+
Dialog,
39+
DialogContent,
40+
DialogDescription,
41+
DialogFooter,
42+
DialogHeader,
43+
DialogTitle,
44+
} from '@/components/ui/dialog';
3645
import { useProjectDetail, useRetryProvisioning, useUpdateHostname, useDeleteProject } from '@/hooks/useProjects';
3746
import { useClient } from '@objectstack/client-react';
3847
import { useProductionGuard } from '@/components/production-guard';
@@ -52,6 +61,8 @@ function ProjectOverviewComponent() {
5261
const { remove: deleteProject, deleting } = useDeleteProject();
5362
const [hostnameEditing, setHostnameEditing] = useState(false);
5463
const [hostnameInput, setHostnameInput] = useState('');
64+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
65+
const [deleteConfirmText, setDeleteConfirmText] = useState('');
5566

5667
const project = detail?.project;
5768
const provisioningError =
@@ -134,18 +145,16 @@ function ProjectOverviewComponent() {
134145
}
135146
};
136147

137-
const handleDelete = async () => {
148+
const handleConfirmDelete = async () => {
138149
if (!project) return;
139-
const ok = await guard.confirm({
140-
title: `Delete project "${project.display_name}"?`,
141-
description:
142-
'This permanently deletes the project, its credentials, members, package installations, and the underlying physical database. This action cannot be undone.',
143-
confirmLabel: 'Delete project',
144-
confirmVariant: 'destructive',
145-
requireTypedConfirmation: true,
146-
typedConfirmationValue: project.display_name,
147-
});
148-
if (!ok) return;
150+
if (deleteConfirmText !== project.display_name) {
151+
toast({
152+
title: 'Confirmation does not match',
153+
description: `Type "${project.display_name}" to confirm deletion.`,
154+
variant: 'destructive',
155+
});
156+
return;
157+
}
149158
try {
150159
const result = await deleteProject(project.id, { force: project.is_default });
151160
const warnings = (result as any)?.warnings as string[] | undefined;
@@ -156,6 +165,8 @@ function ProjectOverviewComponent() {
156165
: `${project.display_name} and its database have been removed.`,
157166
variant: warnings?.length ? 'destructive' : undefined,
158167
});
168+
setDeleteDialogOpen(false);
169+
setDeleteConfirmText('');
159170
navigate({ to: '/projects' });
160171
} catch (err) {
161172
toast({
@@ -456,26 +467,128 @@ function ProjectOverviewComponent() {
456467

457468
<Separator />
458469

459-
<div className="flex justify-end">
460-
<Button
461-
variant="destructive"
462-
size="sm"
463-
className="gap-2"
464-
disabled={deleting}
465-
onClick={handleDelete}
466-
>
467-
{deleting ? (
468-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
469-
) : (
470+
{/* Danger zone — GitHub/Vercel-style cascade-delete card. */}
471+
<Card className="border-destructive/40 p-5">
472+
<h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-destructive">
473+
<AlertTriangle className="h-3.5 w-3.5" />
474+
Danger zone
475+
</h2>
476+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
477+
<div className="text-sm">
478+
<p className="font-medium">Delete this project</p>
479+
<p className="text-muted-foreground">
480+
Once deleted, the project, its credentials, members, package
481+
installations, and the underlying database are gone forever.
482+
</p>
483+
</div>
484+
<Button
485+
variant="destructive"
486+
size="sm"
487+
className="gap-2 self-start sm:self-auto"
488+
disabled={deleting}
489+
onClick={() => setDeleteDialogOpen(true)}
490+
>
470491
<Trash className="h-3.5 w-3.5" />
471-
)}
472-
{deleting ? 'Deleting…' : 'Delete project'}
473-
</Button>
474-
</div>
492+
Delete project
493+
</Button>
494+
</div>
495+
</Card>
475496
</>
476497
)}
477498
</div>
478499
</div>
500+
501+
{/* Delete Project Dialog (GitHub/Vercel-style typed confirmation) */}
502+
<Dialog
503+
open={deleteDialogOpen}
504+
onOpenChange={(open) => {
505+
if (deleting) return;
506+
setDeleteDialogOpen(open);
507+
if (!open) setDeleteConfirmText('');
508+
}}
509+
>
510+
<DialogContent className="sm:max-w-lg">
511+
<DialogHeader>
512+
<DialogTitle className="flex items-center gap-2 text-destructive">
513+
<AlertTriangle className="h-5 w-5" />
514+
Delete project
515+
</DialogTitle>
516+
<DialogDescription>
517+
This action <strong>cannot be undone</strong>. This will permanently
518+
delete the <strong>{project?.display_name}</strong> project, its
519+
credentials, members, package installations, and the underlying
520+
physical database.
521+
</DialogDescription>
522+
</DialogHeader>
523+
524+
{project && (
525+
<div className="my-2 space-y-1.5 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs">
526+
<div className="flex flex-col gap-0.5">
527+
<span className="text-muted-foreground">Project</span>
528+
<span className="font-medium">{project.display_name}</span>
529+
</div>
530+
<div className="flex flex-col gap-0.5">
531+
<span className="text-muted-foreground">ID</span>
532+
<code className="break-all font-mono">{project.id}</code>
533+
</div>
534+
{project.database_url && (
535+
<div className="flex flex-col gap-0.5">
536+
<span className="text-muted-foreground">Database</span>
537+
<code className="break-all font-mono">{project.database_url}</code>
538+
</div>
539+
)}
540+
</div>
541+
)}
542+
543+
<div className="grid gap-1.5">
544+
<Label htmlFor="delete-project-confirm">
545+
Please type{' '}
546+
<code className="font-mono text-xs">{project?.display_name}</code>{' '}
547+
to confirm.
548+
</Label>
549+
<Input
550+
id="delete-project-confirm"
551+
value={deleteConfirmText}
552+
onChange={(e) => setDeleteConfirmText(e.target.value)}
553+
placeholder={project?.display_name ?? ''}
554+
autoComplete="off"
555+
autoFocus
556+
disabled={deleting}
557+
/>
558+
</div>
559+
560+
<DialogFooter>
561+
<Button
562+
variant="ghost"
563+
onClick={() => {
564+
setDeleteDialogOpen(false);
565+
setDeleteConfirmText('');
566+
}}
567+
disabled={deleting}
568+
>
569+
Cancel
570+
</Button>
571+
<Button
572+
variant="destructive"
573+
onClick={handleConfirmDelete}
574+
disabled={
575+
deleting ||
576+
!project ||
577+
deleteConfirmText !== project.display_name
578+
}
579+
>
580+
{deleting ? (
581+
<>
582+
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
583+
Deleting…
584+
</>
585+
) : (
586+
'I understand, delete this project'
587+
)}
588+
</Button>
589+
</DialogFooter>
590+
</DialogContent>
591+
</Dialog>
479592
</main>
480593
);
481594
}

0 commit comments

Comments
 (0)