Theia rip-out: - Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim) - Delete app/api/projects/[projectId]/workspace/route.ts and app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning) - Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts - Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/ theiaError from app/api/projects/create/route.ts response - Remove Theia callbackUrl branch in app/auth/page.tsx - Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx - Drop theiaWorkspaceUrl from deployment/page.tsx Project type - Strip Theia IDE line + theia-code-os from advisor + agent-chat context strings - Scrub Theia mention from lib/auth/workspace-auth.ts comment P5.1 (custom apex domains + DNS): - lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS XML auth, Cloud DNS plumbing - scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS + prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup In-progress (Justine onboarding/build, MVP setup, agent telemetry): - New (justine)/stories, project (home) layouts, mvp-setup, run, tasks routes + supporting components - Project shell + sidebar + nav refactor for the Stackless palette - Agent session API hardening (sessions, events, stream, approve, retry, stop) + atlas-chat, advisor, design-surfaces refresh - New scripts/sync-db-url-from-coolify.mjs + scripts/prisma-db-push.mjs + docker-compose.local-db.yml for local Prisma workflows - lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts - Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel Made-with: Cursor
587 lines
22 KiB
TypeScript
587 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useSession } from "next-auth/react";
|
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
|
import { Loader2, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { ProjectCreationModal } from "@/components/project-creation-modal";
|
|
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
const justineJakarta = Plus_Jakarta_Sans({
|
|
subsets: ["latin"],
|
|
weight: ["400", "500", "600", "700", "800"],
|
|
variable: "--font-justine-jakarta",
|
|
display: "swap",
|
|
});
|
|
|
|
interface ProjectWithStats {
|
|
id: string;
|
|
productName: string;
|
|
productVision?: string;
|
|
status?: string;
|
|
updatedAt: string | null;
|
|
stats: { sessions: number; costs: number };
|
|
}
|
|
|
|
const ICON_BG = ["#6366F1", "#8B5CF6", "#06B6D4", "#EC4899", "#9CA3AF"];
|
|
|
|
function timeAgo(dateStr?: string | null): string {
|
|
if (!dateStr) return "—";
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return "—";
|
|
const diff = (Date.now() - date.getTime()) / 1000;
|
|
if (diff < 60) return "just now";
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
const days = Math.floor(diff / 86400);
|
|
if (days === 1) return "Yesterday";
|
|
if (days < 7) return `${days}d ago`;
|
|
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
return `${Math.floor(days / 30)}mo ago`;
|
|
}
|
|
|
|
function greetingName(session: { user?: { name?: string | null; email?: string | null } } | null): string {
|
|
const n = session?.user?.name?.trim();
|
|
if (n) return n.split(/\s+/)[0] ?? "there";
|
|
const e = session?.user?.email;
|
|
if (e) return e.split("@")[0] ?? "there";
|
|
return "there";
|
|
}
|
|
|
|
function greetingPrefix(): string {
|
|
const h = new Date().getHours();
|
|
if (h < 12) return "Good morning";
|
|
if (h < 17) return "Good afternoon";
|
|
return "Good evening";
|
|
}
|
|
|
|
function StatusPill({ status }: { status?: string }) {
|
|
if (status === "live") {
|
|
return (
|
|
<span className="pill pill-live">
|
|
<span className="dot-live" />
|
|
Live
|
|
</span>
|
|
);
|
|
}
|
|
if (status === "building") {
|
|
return (
|
|
<span className="pill pill-building">
|
|
<span className="dot-building" />
|
|
Building
|
|
</span>
|
|
);
|
|
}
|
|
return <span className="pill pill-draft">Defining</span>;
|
|
}
|
|
|
|
export function JustineWorkspaceProjectsDashboard({ workspace }: { workspace: string }) {
|
|
const { data: session, status: authStatus } = useSession();
|
|
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [showNew, setShowNew] = useState(false);
|
|
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const t = localStorage.getItem("jd-dashboard-theme");
|
|
if (t === "dark") setTheme("dark");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setTheme((prev) => {
|
|
const next = prev === "light" ? "dark" : "light";
|
|
try {
|
|
localStorage.setItem("jd-dashboard-theme", next);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const fetchProjects = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await fetch("/api/projects");
|
|
if (!res.ok) throw new Error("Failed");
|
|
const data = await res.json();
|
|
setProjects(data.projects ?? []);
|
|
} catch {
|
|
/* silent */
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isClientDevProjectBypass()) {
|
|
void fetchProjects();
|
|
return;
|
|
}
|
|
if (authStatus === "authenticated") fetchProjects();
|
|
else if (authStatus === "unauthenticated") setLoading(false);
|
|
}, [authStatus, fetchProjects]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return projects;
|
|
return projects.filter((p) => p.productName.toLowerCase().includes(q));
|
|
}, [projects, search]);
|
|
|
|
const liveN = projects.filter((p) => p.status === "live").length;
|
|
const buildingN = projects.filter((p) => p.status === "building").length;
|
|
const totalCosts = projects.reduce((s, p) => s + (p.stats?.costs ?? 0), 0);
|
|
|
|
const userInitial =
|
|
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
|
const displayName = session?.user?.name?.trim() || session?.user?.email?.split("@")[0] || "Account";
|
|
|
|
const handleDelete = async () => {
|
|
if (!projectToDelete) return;
|
|
setIsDeleting(true);
|
|
try {
|
|
const res = await fetch("/api/projects/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ projectId: projectToDelete.id }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success("Project deleted");
|
|
setProjectToDelete(null);
|
|
fetchProjects();
|
|
} else {
|
|
const err = await res.json();
|
|
toast.error(err.error || "Failed to delete");
|
|
}
|
|
} catch {
|
|
toast.error("An error occurred");
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const firstName = greetingName(session);
|
|
|
|
return (
|
|
<div
|
|
data-justine-dashboard
|
|
data-theme={theme === "dark" ? "dark" : undefined}
|
|
className={`${justineJakarta.variable} justine-dashboard-root`}
|
|
>
|
|
<nav
|
|
className="jd-topnav"
|
|
style={{
|
|
background: "rgba(250,250,250,0.97)",
|
|
borderBottom: "1px solid #E5E7EB",
|
|
height: 56,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 24px",
|
|
flexShrink: 0,
|
|
backdropFilter: "blur(8px)",
|
|
}}
|
|
>
|
|
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}>
|
|
<div
|
|
style={{
|
|
width: 27,
|
|
height: 27,
|
|
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
|
|
borderRadius: 6,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<span className="f" style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>
|
|
V
|
|
</span>
|
|
</div>
|
|
<span className="f" style={{ fontSize: 17, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
|
|
vibn
|
|
</span>
|
|
</Link>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
|
<button type="button" className="btn-secondary mob-hide" onClick={toggleTheme} style={{ fontSize: 12.5, padding: "7px 14px" }}>
|
|
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
|
|
</button>
|
|
<Link
|
|
href={`/${workspace}/settings`}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
cursor: "pointer",
|
|
padding: "5px 8px",
|
|
borderRadius: 8,
|
|
border: "none",
|
|
background: "transparent",
|
|
fontFamily: "var(--sans)",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 29,
|
|
height: 29,
|
|
borderRadius: "50%",
|
|
background: "#6366F1",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 11,
|
|
color: "#FFFFFF",
|
|
fontWeight: 600,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{userInitial}
|
|
</div>
|
|
<div className="mob-hide" style={{ textAlign: "left" }}>
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)", lineHeight: 1.2 }}>{displayName}</div>
|
|
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Workspace · {workspace}</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="app-shell">
|
|
<aside className="proj-nav">
|
|
<div style={{ flexShrink: 0 }}>
|
|
<div style={{ padding: "10px 8px 8px" }}>
|
|
<Link
|
|
href={`/${workspace}/projects`}
|
|
id="nav-dashboard"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 11,
|
|
padding: "11px 12px",
|
|
borderRadius: 10,
|
|
border: "none",
|
|
background: "rgba(255,255,255,0.55)",
|
|
cursor: "pointer",
|
|
width: "100%",
|
|
textAlign: "left",
|
|
fontFamily: "var(--sans)",
|
|
transition: "background 0.18s ease",
|
|
backdropFilter: "blur(4px)",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 8,
|
|
background: "linear-gradient(135deg,#4338CA,#6366F1)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 15,
|
|
flexShrink: 0,
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
⬟
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>Dashboard</div>
|
|
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Overview</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
|
|
<div style={{ padding: "10px 8px 8px", marginTop: 2 }}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 4px", marginBottom: 8 }}>
|
|
<div className="nav-group-label" style={{ margin: 0 }}>
|
|
Projects
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNew(true)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
fontSize: 18,
|
|
color: "var(--indigo)",
|
|
padding: "0 4px",
|
|
lineHeight: 1,
|
|
fontWeight: 300,
|
|
}}
|
|
title="New project"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
<div className="nav-search-wrap">
|
|
<svg
|
|
style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
|
width={12}
|
|
height={12}
|
|
viewBox="0 0 20 20"
|
|
fill="none"
|
|
aria-hidden
|
|
>
|
|
<path
|
|
d="M8.5 3a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 8.5a6.5 6.5 0 1111.436 4.23l3.857 3.857a.75.75 0 01-1.06 1.06l-3.857-3.857A6.5 6.5 0 012 8.5z"
|
|
fill="#9CA3AF"
|
|
/>
|
|
</svg>
|
|
<input
|
|
className="nav-search"
|
|
type="search"
|
|
placeholder="Search projects…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
aria-label="Search projects"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="proj-list">
|
|
{loading && (
|
|
<div style={{ display: "flex", justifyContent: "center", padding: 24 }}>
|
|
<Loader2 style={{ width: 22, height: 22, color: "var(--muted)" }} className="animate-spin" />
|
|
</div>
|
|
)}
|
|
{!loading && filtered.length === 0 && projects.length === 0 && (
|
|
<div style={{ padding: "20px 8px 12px", textAlign: "center" }}>
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
background: "var(--indigo-dim)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
margin: "0 auto 10px",
|
|
fontSize: 18,
|
|
}}
|
|
>
|
|
✦
|
|
</div>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", marginBottom: 4 }}>No projects yet</div>
|
|
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12, lineHeight: 1.5 }}>
|
|
Start building your first product with vibn.
|
|
</div>
|
|
<button type="button" className="btn-primary" style={{ fontSize: 11.5, padding: "7px 14px", width: "100%" }} onClick={() => setShowNew(true)}>
|
|
+ Create first project
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!loading && projects.length > 0 && filtered.length === 0 && (
|
|
<div style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--muted)" }}>No projects match your search.</div>
|
|
)}
|
|
{!loading &&
|
|
filtered.map((p, i) => (
|
|
<div key={p.id} className="proj-row">
|
|
<Link
|
|
href={`/${workspace}/project/${p.id}`}
|
|
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "flex-start", gap: 10, textDecoration: "none", color: "inherit" }}
|
|
>
|
|
<div
|
|
className="proj-icon"
|
|
style={{
|
|
background: ICON_BG[i % ICON_BG.length],
|
|
}}
|
|
>
|
|
{(p.productName[0] ?? "P").toUpperCase()}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="proj-row-name">
|
|
{p.productName} <StatusPill status={p.status} />
|
|
</div>
|
|
<div className="proj-row-metric" style={{ fontWeight: 400, color: "var(--muted)" }}>
|
|
{p.productVision ? `${p.productVision.slice(0, 42)}${p.productVision.length > 42 ? "…" : ""}` : "Personal"}
|
|
</div>
|
|
<div className="proj-row-time">{timeAgo(p.updatedAt)}</div>
|
|
</div>
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="proj-edit-btn"
|
|
title="Delete project"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setProjectToDelete(p);
|
|
}}
|
|
>
|
|
<Trash2 style={{ width: 13, height: 13 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ flex: 1, padding: "12px 8px 16px", display: "flex", flexDirection: "column" }}>
|
|
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
|
Workspace
|
|
</div>
|
|
<Link href={`/${workspace}/activity`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
|
<div className="nav-icon">↗</div>
|
|
<div>
|
|
<div className="nav-label">Activity</div>
|
|
<div className="nav-sub">Timeline & runs</div>
|
|
</div>
|
|
</Link>
|
|
<Link href={`/${workspace}/settings`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
|
|
<div className="nav-icon">⚙</div>
|
|
<div className="nav-label">Settings</div>
|
|
</Link>
|
|
<div style={{ height: 1, background: "var(--border)", margin: "8px 4px" }} />
|
|
<div className="nav-group-label" style={{ marginBottom: 4 }}>
|
|
Account
|
|
</div>
|
|
<button type="button" className="nav-item-btn" onClick={() => toast.message("Help — docs coming soon.")}>
|
|
<div className="nav-icon">?</div>
|
|
<div className="nav-label">Help</div>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="workspace">
|
|
<div id="ws-dashboard" className="ws-section active">
|
|
<div className="ws-inner">
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28 }}>
|
|
<div>
|
|
<h1 className="f" style={{ fontSize: 28, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.03em", marginBottom: 7 }}>
|
|
{greetingPrefix()}, {firstName}.
|
|
</h1>
|
|
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.5 }}>
|
|
Open a project from the sidebar or start a new one.
|
|
</p>
|
|
</div>
|
|
<div className="dash-header-actions" style={{ display: "flex", gap: 9, alignItems: "center", flexShrink: 0, paddingTop: 4 }}>
|
|
<button type="button" className="btn-primary" onClick={() => setShowNew(true)}>
|
|
+ New project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!loading && projects.length === 0 && (
|
|
<div style={{ marginBottom: 36 }}>
|
|
<div
|
|
style={{
|
|
background: "var(--white)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 16,
|
|
padding: "48px 36px",
|
|
textAlign: "center",
|
|
maxWidth: 480,
|
|
margin: "0 auto",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 14,
|
|
background: "linear-gradient(135deg,rgba(99,102,241,0.12),rgba(139,92,246,0.12))",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
margin: "0 auto 20px",
|
|
fontSize: 26,
|
|
}}
|
|
>
|
|
✦
|
|
</div>
|
|
<h2 className="f" style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", marginBottom: 8, letterSpacing: "-0.02em" }}>
|
|
Build your first product
|
|
</h2>
|
|
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 24 }}>
|
|
Describe your idea, and vibn will architect, design, and help you ship it — no code required.
|
|
</p>
|
|
<button type="button" className="btn-primary" style={{ padding: "11px 28px", fontSize: 14 }} onClick={() => setShowNew(true)}>
|
|
+ Start a new project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="dash-section-title">Portfolio snapshot</div>
|
|
<div className="snap-grid">
|
|
<div className="snap-card">
|
|
<div className="snap-value">{projects.length}</div>
|
|
<div className="snap-label">Active projects</div>
|
|
</div>
|
|
<div className="snap-card">
|
|
<div className="snap-value" style={{ color: "var(--green)" }}>
|
|
{liveN}
|
|
</div>
|
|
<div className="snap-label">Live products</div>
|
|
</div>
|
|
<div className="snap-card">
|
|
<div className="snap-value" style={{ color: "#4338CA" }}>
|
|
{buildingN}
|
|
</div>
|
|
<div className="snap-label">Building now</div>
|
|
</div>
|
|
<div className="snap-card" style={{ borderColor: "var(--amber-border)", background: "var(--amber-dim)" }}>
|
|
<div className="snap-value" style={{ color: "#92400E" }}>
|
|
{totalCosts > 0 ? `$${totalCosts.toFixed(2)}` : "—"}
|
|
</div>
|
|
<div className="snap-label" style={{ color: "#B45309" }}>
|
|
API spend (est.)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<ProjectCreationModal
|
|
open={showNew}
|
|
onOpenChange={(open) => {
|
|
setShowNew(open);
|
|
if (!open) fetchProjects();
|
|
}}
|
|
workspace={workspace}
|
|
/>
|
|
|
|
<AlertDialog open={!!projectToDelete} onOpenChange={(open) => !open && setProjectToDelete(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete "{projectToDelete?.productName}"?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
|
|
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
|
|
Delete project
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|