Toast action results and confirm destructive actions in one dialog
Inline banners and per-row status text reported action results all over the app; they are replaced by a small toast stack (bottom-right, Level 3 floating well styling) that only speaks for actions: successes fade, errors stay until dismissed, and form-field validation stays inline. Destructive actions no longer use bespoke inline confirm steps (the library detail's Confirm delete button) or fire immediately (duplicate copies/items, delete page selections, temp cleanup, upload discard, job cancellation): they all go through one promise-based confirm dialog (confirmAction) with a danger variant, Escape/backdrop to cancel.
This commit is contained in:
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigationType } from "react-router-dom";
|
||||
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { StatusFooter } from "@/components/StatusFooter";
|
||||
import { StatusPill } from "@/components/StatusPill";
|
||||
import { Toasts } from "@/components/Toasts";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { SystemStatus } from "@/lib/types";
|
||||
@@ -182,6 +184,8 @@ export function AppShell() {
|
||||
)}
|
||||
/>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
<ConfirmDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useConfirm } from "@/store/confirm";
|
||||
|
||||
export function ConfirmDialog() {
|
||||
const request = useConfirm((state) => state.request);
|
||||
const settle = useConfirm((state) => state.settle);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") settle(false);
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [request, settle]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-ctp-crust/60 p-4 backdrop-blur-sm"
|
||||
onClick={() => settle(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={request.title}
|
||||
className="w-full max-w-sm rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4 shadow-[0_4px_12px_rgba(17,17,27,0.6)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-ctp-text">{request.title}</h2>
|
||||
{request.description ? (
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-ctp-subtext0">
|
||||
{request.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" autoFocus onClick={() => settle(false)}>
|
||||
{request.cancelLabel ?? "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={request.danger ? "danger" : "primary"}
|
||||
onClick={() => settle(true)}
|
||||
>
|
||||
{request.confirmLabel ?? "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useToasts, type ToastKind } from "@/store/toasts";
|
||||
|
||||
const KIND_STYLES: Record<ToastKind, string> = {
|
||||
ok: "border-ctp-green/40",
|
||||
error: "border-ctp-red/40",
|
||||
info: "border-ctp-blue/40",
|
||||
};
|
||||
|
||||
const KIND_ICONS: Record<ToastKind, LucideIcon> = {
|
||||
ok: CheckCircle2,
|
||||
error: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const ICON_STYLES: Record<ToastKind, string> = {
|
||||
ok: "text-ctp-green",
|
||||
error: "text-ctp-red",
|
||||
info: "text-ctp-blue",
|
||||
};
|
||||
|
||||
export function Toasts() {
|
||||
const toasts = useToasts((state) => state.toasts);
|
||||
const dismiss = useToasts((state) => state.dismiss);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed bottom-12 right-4 z-[60] flex w-[min(22rem,calc(100vw-2rem))] flex-col gap-2"
|
||||
>
|
||||
{toasts.map((item) => {
|
||||
const Icon = KIND_ICONS[item.kind];
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role="status"
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-2 rounded-lg border bg-ctp-surface0 p-3",
|
||||
"shadow-[0_4px_12px_rgba(17,17,27,0.6)]",
|
||||
KIND_STYLES[item.kind],
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", ICON_STYLES[item.kind])} />
|
||||
<p className="flex-1 text-sm leading-snug text-ctp-text">{item.text}</p>
|
||||
{item.action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
item.action?.onClick();
|
||||
dismiss(item.id);
|
||||
}}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-surface1"
|
||||
>
|
||||
{item.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 rounded p-0.5 text-ctp-overlay0 transition hover:bg-ctp-surface1 hover:text-ctp-text"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user