row-delete-project-button.tsx150 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState } from 'react';
5
6interface Props {
7 projectId: string;
8 projectName: string;
9 apiOrigin: string;
10}
11
12interface ErrorBody {
13 code?: string;
14 message?: string;
15}
16
17/**
18 * Compact trash-icon delete control for the projects list row.
19 * Two-step: icon click arms a typed-name confirm; second click sends the
20 * DELETE. If the api returns step_up_required, we route to the project's
21 * settings page where the full step-up prompt lives — keeps this row-level
22 * control small while still allowing the destructive path to complete.
23 */
24export function RowDeleteProjectButton({ projectId, projectName, apiOrigin }: Props) {
25 const router = useRouter();
26 const [open, setOpen] = useState(false);
27 const [confirm, setConfirm] = useState('');
28 const [busy, setBusy] = useState(false);
29 const [error, setError] = useState<string | null>(null);
30
31 const matches = confirm === projectName;
32
33 async function performDelete(e: React.MouseEvent) {
34 e.preventDefault();
35 e.stopPropagation();
36 setBusy(true);
37 setError(null);
38 try {
39 const res = await fetch(`${apiOrigin}/v1/projects/${projectId}`, {
40 method: 'DELETE',
41 credentials: 'include',
42 });
43 if (res.ok) {
44 router.refresh();
45 return;
46 }
47 if (res.status === 403) {
48 const body = (await res.json().catch(() => null)) as ErrorBody | null;
49 if (body?.code === 'step_up_required') {
50 router.push(`/dashboard/projects/${projectId}/settings#delete`);
51 return;
52 }
53 }
54 const text = await res.text().catch(() => '');
55 throw new Error(text || `delete failed: ${res.status}`);
56 } catch (err) {
57 setError(err instanceof Error ? err.message : 'delete failed');
58 } finally {
59 setBusy(false);
60 }
61 }
62
63 if (!open) {
64 return (
65 <button
66 type="button"
67 aria-label={`delete ${projectName}`}
68 title="delete project"
69 onClick={(e) => {
70 e.preventDefault();
71 e.stopPropagation();
72 setOpen(true);
73 }}
74 className="rounded-md border border-transparent p-1.5 text-[var(--color-text-subtle)] transition hover:border-[var(--color-error)] hover:text-[var(--color-error)]"
75 >
76 <TrashIcon />
77 </button>
78 );
79 }
80
81 return (
82 <div
83 onClick={(e) => {
84 e.preventDefault();
85 e.stopPropagation();
86 }}
87 className="flex flex-col gap-2 rounded-md border border-[var(--color-error)] bg-[var(--color-bg)] p-2 text-left"
88 >
89 <p className="font-mono text-[10px] text-[var(--color-text)]">
90 type <span className="text-[var(--color-error)]">{projectName}</span> to confirm:
91 </p>
92 <input
93 value={confirm}
94 autoFocus
95 onChange={(e) => setConfirm(e.currentTarget.value)}
96 onClick={(e) => e.stopPropagation()}
97 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-xs"
98 />
99 <div className="flex gap-2">
100 <button
101 type="button"
102 disabled={!matches || busy}
103 onClick={performDelete}
104 className="rounded-md bg-[var(--color-error)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-inverse)] disabled:opacity-30"
105 >
106 {busy ? 'deleting…' : 'delete'}
107 </button>
108 <button
109 type="button"
110 disabled={busy}
111 onClick={(e) => {
112 e.preventDefault();
113 e.stopPropagation();
114 setOpen(false);
115 setConfirm('');
116 setError(null);
117 }}
118 className="rounded-md border border-[var(--color-border)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
119 >
120 cancel
121 </button>
122 </div>
123 {error ? (
124 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
125 ) : null}
126 </div>
127 );
128}
129
130function TrashIcon() {
131 return (
132 <svg
133 width="14"
134 height="14"
135 viewBox="0 0 24 24"
136 fill="none"
137 stroke="currentColor"
138 strokeWidth="2"
139 strokeLinecap="round"
140 strokeLinejoin="round"
141 aria-hidden
142 >
143 <polyline points="3 6 5 6 21 6" />
144 <path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6" />
145 <path d="M10 11v6" />
146 <path d="M14 11v6" />
147 <path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
148 </svg>
149 );
150}