feat: HQ-wide Toast + Confirm — no more browser alert()/confirm() popups
Deploy to VPS / deploy (push) Has been cancelled

The Inbox panel got the polished Toast + Confirm primitives a few
commits back. This commit propagates them across every other panel in
HQ Command so the editor experience is uniformly on-brand. No more
1990s browser dialogs interrupting the dark CMS look.

NINE PANELS, ELEVEN CALL SITES UPGRADED
- health/page.tsx       — DB export errors → toast
- network/page.tsx      — Delete deployment → confirm + toast
- heritage/page.tsx     — Delete section → confirm + toast
- users/page.tsx        — Revoke architect → confirm + error toast
- news/page.tsx         — Delete article → confirm + toast
- applications/page.tsx — Save error toast + delete-app confirm
- parts/page.tsx        — Delete component → confirm + toast
- hero/page.tsx         — Delete slide → confirm + toast
- timeline/page.tsx     — Delete milestone → confirm + toast

Each destructive confirm now spells out what it does ('Permanently
remove this case from the global map. The asset folder on disk is
kept for safety') instead of a generic 'Delete?' prompt — much
clearer for editors who aren't sure whether files get nuked too.

Each success toast names the action ('Component deleted', 'Slide
deleted', 'Architect access revoked') so the editor sees exactly
what fired. Errors come in as red toasts with the actual error text.

NO BACKEND CHANGES. Pure UX layer on top of existing actions.
The HqUiProvider was already mounted in src/app/hq-command/layout.tsx,
so wiring up was just useHqUi() per page + the replacement calls.
This commit is contained in:
2026-05-05 21:16:02 -05:00
parent e177bca92f
commit 59a146ef10
9 changed files with 109 additions and 14 deletions
@@ -10,6 +10,7 @@ import {
Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check
} from "lucide-react"; } from "lucide-react";
import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions"; import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
// AssetBucketBrowser is the unified picker. The applications page uses an // AssetBucketBrowser is the unified picker. The applications page uses an
@@ -245,6 +246,7 @@ function MarkdownEditor({ name, defaultValue = "", required, rows = 12, placehol
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function ApplicationsManager() { export default function ApplicationsManager() {
const ui = useHqUi();
const router = useRouter(); const router = useRouter();
const [apps, setApps] = useState<any[]>([]); const [apps, setApps] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -273,8 +275,14 @@ export default function ApplicationsManager() {
formData.append("sectionsJson", JSON.stringify(sections)); formData.append("sectionsJson", JSON.stringify(sections));
formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics)); formData.append("dashboardMetricsJson", JSON.stringify(dashboardMetrics));
const res = await updateApplicationData(formData); const res = await updateApplicationData(formData);
if (res.error) { alert("Error saving data: " + res.error); } if (res.error) {
else { setEditingApp(null); await fetchApps(); router.refresh(); } ui.toast(`Error saving data: ${res.error}`, "error");
} else {
ui.toast("Application saved.", "success");
setEditingApp(null);
await fetchApps();
router.refresh();
}
setIsSubmitting(false); setIsSubmitting(false);
}; };
@@ -311,7 +319,18 @@ export default function ApplicationsManager() {
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td> <td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td> <td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td> <td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={() => { if(confirm("Delete this application forever?")) { deleteApplication(app.slug); fetchApps(); } }} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td> <td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEditModal(app)} className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all ${isPopulated ? 'bg-white/5 text-white hover:bg-white/10' : 'bg-purple-500 text-white hover:bg-purple-400'}`}><DatabaseZap size={14} /> Manage Core</button><button onClick={async () => {
const ok = await ui.confirm({
title: "Delete application",
message: `Permanently remove "${app.title}" from the catalog. The asset folder on disk is preserved.`,
confirmLabel: "Delete forever",
destructive: true,
});
if (!ok) return;
await deleteApplication(app.slug);
ui.toast("Application deleted.", "success");
fetchApps();
}} className="text-[#86868B] hover:text-red-400 p-2 rounded-lg transition-colors opacity-0 group-hover:opacity-100"><Trash2 size={18}/></button></div></td>
</tr> </tr>
); );
})} })}
+3 -1
View File
@@ -7,8 +7,10 @@ import {
DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2 DownloadCloud, ShieldAlert, UploadCloud, Loader2, CheckCircle2
} from "lucide-react"; } from "lucide-react";
import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions"; import { getSystemMetrics, exportDatabase, restoreDatabase } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
export default function SystemHealth() { export default function SystemHealth() {
const ui = useHqUi();
const [metrics, setMetrics] = useState<any>(null); const [metrics, setMetrics] = useState<any>(null);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@@ -54,7 +56,7 @@ export default function SystemHealth() {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
alert(res.error || "Export failed."); ui.toast(res.error || "Export failed.", "error");
} }
setIsExporting(false); setIsExporting(false);
}; };
+10 -1
View File
@@ -15,6 +15,7 @@ import {
reorderHeritageSections, reorderHeritageSections,
createHeritageStub, createHeritageStub,
} from "./actions"; } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface SectionRow { interface SectionRow {
id: string; id: string;
@@ -33,6 +34,7 @@ const TYPE_META: Record<string, { label: string; icon: typeof ImageIcon; color:
}; };
export default function HeritageManager() { export default function HeritageManager() {
const ui = useHqUi();
const [sections, setSections] = useState<SectionRow[]>([]); const [sections, setSections] = useState<SectionRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
@@ -72,8 +74,15 @@ export default function HeritageManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Delete this section? This cannot be undone.")) return; const ok = await ui.confirm({
title: "Delete section",
message: "Permanently remove this section from the heritage page. This cannot be undone.",
confirmLabel: "Delete section",
destructive: true,
});
if (!ok) return;
await deleteHeritageSection(id); await deleteHeritageSection(id);
ui.toast("Section deleted.", "success");
await load(); await load();
}; };
+10 -1
View File
@@ -19,6 +19,7 @@ import {
importFootageFiles, importFootageFiles,
type FootageFile, type FootageFile,
} from "./actions"; } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface SlideRow { interface SlideRow {
id: string; id: string;
@@ -38,6 +39,7 @@ function safeParseJson<T>(json: string | null | undefined, fallback: T): any {
} }
export default function HeroDashboard() { export default function HeroDashboard() {
const ui = useHqUi();
const [slides, setSlides] = useState<SlideRow[]>([]); const [slides, setSlides] = useState<SlideRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
@@ -142,8 +144,15 @@ export default function HeroDashboard() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Delete this slide? The image file stays on disk and can be re-added.")) return; const ok = await ui.confirm({
title: "Delete slide",
message: "Removes the slide from the carousel. The image file stays on disk and can be re-imported later.",
confirmLabel: "Delete slide",
destructive: true,
});
if (!ok) return;
await deleteHeroSlide(id); await deleteHeroSlide(id);
ui.toast("Slide deleted.", "success");
await loadSlides(); await loadSlides();
}; };
+14 -1
View File
@@ -17,6 +17,7 @@ import { getApplications } from "../applications/actions";
// AssetBucketBrowser is the unified picker — single source of truth across HQ. // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// Aliased to AssetManager so existing JSX call sites remain untouched. // Aliased to AssetManager so existing JSX call sites remain untouched.
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
const AssetManager = AssetBucketBrowser; const AssetManager = AssetBucketBrowser;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -176,6 +177,7 @@ function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 10, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function NetworkManager() { export default function NetworkManager() {
const ui = useHqUi();
const [nodes, setNodes] = useState<any[]>([]); const [nodes, setNodes] = useState<any[]>([]);
const [appsList, setAppsList] = useState<any[]>([]); const [appsList, setAppsList] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -262,7 +264,18 @@ export default function NetworkManager() {
setIsSavingCaseStudy(false); setIsSavingCaseStudy(false);
}; };
const handleDelete = async (id: string) => { if (confirm("Delete this deployment?")) { await deleteNode(id); fetchNodesAndApps(); } }; const handleDelete = async (id: string) => {
const ok = await ui.confirm({
title: "Delete deployment",
message: "Permanently remove this case from the global map. The asset folder on disk is kept for safety.",
confirmLabel: "Delete deployment",
destructive: true,
});
if (!ok) return;
await deleteNode(id);
ui.toast("Deployment deleted.", "success");
fetchNodesAndApps();
};
const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); }; const handleToggle = async (id: string, status: boolean) => { await toggleNodeStatus(id, status); fetchNodesAndApps(); };
const availableTabs = [ const availableTabs = [
+12 -1
View File
@@ -17,6 +17,7 @@ import { getNewsArticles, createNewsArticle, updateNewsArticle, deleteNewsArticl
// AssetBucketBrowser is the unified picker — single source of truth across HQ. // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// Aliased to AssetManager so existing JSX call sites remain untouched. // Aliased to AssetManager so existing JSX call sites remain untouched.
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
const AssetManager = AssetBucketBrowser; const AssetManager = AssetBucketBrowser;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -153,6 +154,7 @@ function MarkdownEditorCyan({ name, defaultValue = "", required, rows = 12, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function NewsManager() { export default function NewsManager() {
const ui = useHqUi();
const [articles, setArticles] = useState<any[]>([]); const [articles, setArticles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -197,7 +199,16 @@ export default function NewsManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Delete this article?")) { await deleteNewsArticle(id); fetchArticles(); } const ok = await ui.confirm({
title: "Delete article",
message: "Permanently remove this news article. The asset folder on disk is preserved for safety.",
confirmLabel: "Delete article",
destructive: true,
});
if (!ok) return;
await deleteNewsArticle(id);
ui.toast("Article deleted.", "success");
fetchArticles();
}; };
return ( return (
+14 -1
View File
@@ -15,6 +15,7 @@ import { getParts, createPart, updatePart, deletePart, togglePartStatus, getPart
// AssetBucketBrowser is the unified picker — single source of truth across HQ. // AssetBucketBrowser is the unified picker — single source of truth across HQ.
// Aliased to AssetManager so existing JSX call sites remain untouched. // Aliased to AssetManager so existing JSX call sites remain untouched.
import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser"; import AssetBucketBrowser from "@/components/hq/AssetBucketBrowser";
import { useHqUi } from "@/components/hq/Toast";
const AssetManager = AssetBucketBrowser; const AssetManager = AssetBucketBrowser;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -98,6 +99,7 @@ function MarkdownEditorAmber({ name, defaultValue = "", required, rows = 8, plac
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export default function PartsManager() { export default function PartsManager() {
const ui = useHqUi();
const [parts, setParts] = useState<any[]>([]); const [parts, setParts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -189,7 +191,18 @@ export default function PartsManager() {
<td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td> <td className="p-6"><p className="font-medium text-white">{part.title}</p><p className="text-xs text-amber-400/70 font-mono mt-1">{part.sku}</p></td>
<td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td> <td className="p-6">{part.showPrice && part.price ? <span className="text-white font-mono">{part.price.toFixed(2)}</span> : <span className="text-[9px] text-[#86868B] uppercase tracking-widest">Quote Based</span>}</td>
<td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td> <td className="p-6"><button onClick={async () => { await togglePartStatus(part.id, part.isActive); fetchInitialData(); }} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 ${part.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{part.isActive ? <><Eye size={12} /> Active</> : <><EyeOff size={12} /> Hidden</>}</button></td>
<td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => { if (confirm("Delete?")) { await deletePart(part.id); fetchInitialData(); } }} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td> <td className="p-6 text-right"><div className="flex justify-end gap-2"><button onClick={() => openEdit(part)} className="text-[#86868B] hover:text-amber-400 hover:bg-amber-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Edit3 size={18} /></button><button onClick={async () => {
const ok = await ui.confirm({
title: "Delete component",
message: `Permanently remove "${part.title}" (SKU ${part.sku}) from the catalog. This cannot be undone.`,
confirmLabel: "Delete component",
destructive: true,
});
if (!ok) return;
await deletePart(part.id);
ui.toast("Component deleted.", "success");
fetchInitialData();
}} className="text-[#86868B] hover:text-red-400 hover:bg-red-400/10 p-2 rounded-lg opacity-0 group-hover:opacity-100"><Trash2 size={18} /></button></div></td>
</tr> </tr>
))} ))}
</tbody></table></div></div> </tbody></table></div></div>
+10 -1
View File
@@ -16,6 +16,7 @@ import {
createTimelineStub, createTimelineStub,
seedTimeline, seedTimeline,
} from "./actions"; } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
interface EventRow { interface EventRow {
id: string; id: string;
@@ -28,6 +29,7 @@ interface EventRow {
} }
export default function TimelineManager() { export default function TimelineManager() {
const ui = useHqUi();
const [events, setEvents] = useState<EventRow[]>([]); const [events, setEvents] = useState<EventRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false); const [seeding, setSeeding] = useState(false);
@@ -65,8 +67,15 @@ export default function TimelineManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm("Delete this milestone? This cannot be undone.")) return; const ok = await ui.confirm({
title: "Delete milestone",
message: "Permanently remove this milestone from the company timeline. This cannot be undone.",
confirmLabel: "Delete milestone",
destructive: true,
});
if (!ok) return;
await deleteTimelineEvent(id); await deleteTimelineEvent(id);
ui.toast("Milestone deleted.", "success");
await load(); await load();
}; };
+13 -3
View File
@@ -5,8 +5,10 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react"; import { ArrowLeft, Users, Plus, Trash2, ShieldCheck, KeyRound, Loader2, X, Settings } from "lucide-react";
import { getUsers, createUser, deleteUser, updateUser } from "./actions"; import { getUsers, createUser, deleteUser, updateUser } from "./actions";
import { useHqUi } from "@/components/hq/Toast";
export default function UsersManager() { export default function UsersManager() {
const ui = useHqUi();
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -74,10 +76,18 @@ export default function UsersManager() {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to revoke this architect's access? This cannot be undone.")) { const ok = await ui.confirm({
title: "Revoke architect access",
message: "Permanently remove this admin account. They will lose access to the Command Center immediately. This cannot be undone.",
confirmLabel: "Revoke access",
destructive: true,
});
if (!ok) return;
const res = await deleteUser(id); const res = await deleteUser(id);
if (res.error) alert(res.error); if (res.error) ui.toast(res.error, "error");
else fetchUsers(); else {
ui.toast("Architect access revoked.", "success");
fetchUsers();
} }
}; };