mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
feat(ui): add ConfirmModal, migrate EditorLayout inline confirms (#897)
Introduce ConfirmModal, an AlertDialog-rooted variant of the §10 modal chrome for Yes-No confirmations. Reuses the cyan or destructive rail, mono kicker, italic serif title, and footer hint via a parameterized HeaderShell that injects Title and Description components so the same helper renders Dialog or AlertDialog primitives correctly. Replace the three inline AlertDialog blocks in EditorLayout (delete stack, unsaved-load, label bulk action) with ConfirmModal. Hoist the label bulk-action handler out of inline JSX and memoize the affected stack list. Async confirms (returning a Promise from onConfirm) keep the dialog open so callers can render running state and close via onOpenChange; sync confirms let Radix auto-close.
This commit is contained in:
@@ -9,7 +9,7 @@ import type { NotificationItem } from './dashboard/types';
|
||||
import BashExecModal from './BashExecModal';
|
||||
import LazyBoundary from './LazyBoundary';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
|
||||
import { ConfirmModal } from './ui/modal';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
@@ -1612,6 +1612,43 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const bulkAffected = useMemo(() => {
|
||||
if (!bulkActionLabel) return [];
|
||||
return Object.entries(stackLabelMap)
|
||||
.filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id))
|
||||
.map(([name]) => name);
|
||||
}, [stackLabelMap, bulkActionLabel]);
|
||||
|
||||
const runLabelBulkAction = async () => {
|
||||
if (!bulkActionLabel) return;
|
||||
setBulkActionRunning(true);
|
||||
try {
|
||||
const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: bulkAction }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `Bulk ${bulkAction} failed.`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success);
|
||||
if (failed.length > 0) {
|
||||
const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', ');
|
||||
toast.error(`Failed to ${bulkAction}: ${failedNames}`);
|
||||
} else {
|
||||
const successVerb = bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted';
|
||||
toast.success(`All stacks ${successVerb} successfully.`);
|
||||
}
|
||||
setBulkActionOpen(false);
|
||||
refreshStacks(true);
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setBulkActionRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Context-menu-friendly stack actions (accept file name directly)
|
||||
const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => {
|
||||
if (isStackBusy(stackFile)) return;
|
||||
@@ -2617,116 +2654,88 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Stack</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {stackToDelete}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center gap-2 px-1 py-1">
|
||||
<Checkbox
|
||||
id="prune-volumes"
|
||||
checked={pruneVolumesOnDelete}
|
||||
onCheckedChange={(v) => setPruneVolumesOnDelete(v === true)}
|
||||
/>
|
||||
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
|
||||
Also remove associated volumes
|
||||
</label>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={deleteStack}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmModal
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
variant="destructive"
|
||||
kicker={`${(stackToDelete ?? 'STACK').toUpperCase()} · REMOVE · IRREVERSIBLE`}
|
||||
title={
|
||||
stackToDelete ? (
|
||||
<>
|
||||
Delete <em className="font-display italic text-destructive">{stackToDelete}</em>?
|
||||
</>
|
||||
) : (
|
||||
'Delete stack?'
|
||||
)
|
||||
}
|
||||
description={`Confirm deletion of ${stackToDelete ?? 'stack'}.`}
|
||||
hint={pruneVolumesOnDelete ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={deleteStack}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="prune-volumes"
|
||||
checked={pruneVolumesOnDelete}
|
||||
onCheckedChange={(v) => setPruneVolumesOnDelete(v === true)}
|
||||
/>
|
||||
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
|
||||
Also remove associated volumes
|
||||
</label>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
|
||||
<AlertDialog open={!!pendingUnsavedLoad} onOpenChange={(open) => { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes. Switching stacks will discard them. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); }}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => {
|
||||
const target = pendingUnsavedLoad;
|
||||
const targetNode = pendingUnsavedNode;
|
||||
// Reset content to original so the guard doesn't re-trigger
|
||||
setContent(originalContent);
|
||||
setEnvContent(originalEnvContent);
|
||||
setPendingUnsavedLoad(null);
|
||||
setPendingUnsavedNode(null);
|
||||
if (target) {
|
||||
if (targetNode) loadFileOnNode(targetNode, target);
|
||||
else loadFile(target);
|
||||
}
|
||||
}}>Discard Changes</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmModal
|
||||
open={!!pendingUnsavedLoad}
|
||||
onOpenChange={(open) => { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }}
|
||||
kicker="EDITOR · UNSAVED CHANGES"
|
||||
title="Discard unsaved changes?"
|
||||
description="You have unsaved changes. Switching stacks will discard them."
|
||||
confirmLabel="Discard changes"
|
||||
onConfirm={() => {
|
||||
const target = pendingUnsavedLoad;
|
||||
const targetNode = pendingUnsavedNode;
|
||||
setContent(originalContent);
|
||||
setEnvContent(originalEnvContent);
|
||||
setPendingUnsavedLoad(null);
|
||||
setPendingUnsavedNode(null);
|
||||
if (target) {
|
||||
if (targetNode) loadFileOnNode(targetNode, target);
|
||||
else loadFile(target);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have unsaved changes. Switching stacks will discard them.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
<AlertDialog open={bulkActionOpen} onOpenChange={setBulkActionOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all “{bulkActionLabel?.name}” stacks?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will {bulkAction} all stacks labeled “{bulkActionLabel?.name}”.
|
||||
{stackLabelMap && bulkActionLabel && (
|
||||
<span className="block mt-2 font-mono text-xs">
|
||||
Affected: {Object.entries(stackLabelMap)
|
||||
.filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id))
|
||||
.map(([name]) => name)
|
||||
.join(', ') || 'none'}
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkActionRunning}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={bulkActionRunning}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!bulkActionLabel) return;
|
||||
setBulkActionRunning(true);
|
||||
try {
|
||||
const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: bulkAction }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `Bulk ${bulkAction} failed.`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success);
|
||||
if (failed.length > 0) {
|
||||
const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', ');
|
||||
toast.error(`Failed to ${bulkAction}: ${failedNames}`);
|
||||
} else {
|
||||
toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`);
|
||||
}
|
||||
setBulkActionOpen(false);
|
||||
refreshStacks(true);
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setBulkActionRunning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} All`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmModal
|
||||
open={bulkActionOpen}
|
||||
onOpenChange={setBulkActionOpen}
|
||||
kicker={`LABEL · ${(bulkAction || 'ACTION').toUpperCase()} ALL`}
|
||||
title={
|
||||
<>
|
||||
{bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all{' '}
|
||||
<em className="font-display italic">“{bulkActionLabel?.name ?? ''}”</em> stacks?
|
||||
</>
|
||||
}
|
||||
description={`${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all stacks labeled "${bulkActionLabel?.name ?? ''}".`}
|
||||
hint={`${bulkAffected.length} AFFECTED`}
|
||||
confirmLabel={bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all`}
|
||||
confirming={bulkActionRunning}
|
||||
onConfirm={runLabelBulkAction}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will {bulkAction || 'apply'} all stacks labeled “{bulkActionLabel?.name ?? ''}”.
|
||||
</p>
|
||||
{bulkAffected.length > 0 && (
|
||||
<p className="font-mono text-xs text-stat-subtitle">
|
||||
Affected: {bulkAffected.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Bash Exec Modal */}
|
||||
{selectedContainer && (
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]';
|
||||
|
||||
@@ -48,25 +57,40 @@ interface ModalHeaderBaseProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type HeaderVariant = 'default' | 'destructive';
|
||||
|
||||
const HEADER_VARIANT: Record<HeaderVariant, { rail: string; kicker: string }> = {
|
||||
default: { rail: 'bg-brand', kicker: 'text-stat-subtitle' },
|
||||
destructive: { rail: 'bg-destructive', kicker: 'text-destructive' },
|
||||
};
|
||||
|
||||
interface HeaderShellProps extends ModalHeaderBaseProps {
|
||||
variant: HeaderVariant;
|
||||
TitleComponent: React.ElementType;
|
||||
DescriptionComponent: React.ElementType;
|
||||
}
|
||||
|
||||
function HeaderShell({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
railClassName,
|
||||
kickerClassName,
|
||||
}: ModalHeaderBaseProps & { railClassName: string; kickerClassName: string }) {
|
||||
variant,
|
||||
TitleComponent,
|
||||
DescriptionComponent,
|
||||
}: HeaderShellProps) {
|
||||
const v = HEADER_VARIANT[variant];
|
||||
return (
|
||||
<div className="relative border-b border-card-border/60 px-6 pt-6 pb-4 pr-12">
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', railClassName)} />
|
||||
<div className={cn(KICKER_CLASS, kickerClassName)}>
|
||||
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', v.rail)} />
|
||||
<div className={cn(KICKER_CLASS, v.kicker)}>
|
||||
{kicker}
|
||||
</div>
|
||||
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
<TitleComponent className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
</TitleComponent>
|
||||
<DescriptionComponent className="sr-only">
|
||||
{description ?? (typeof title === 'string' ? title : kicker)}
|
||||
</DialogDescription>
|
||||
</DescriptionComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,8 +99,9 @@ export function ModalHeader(props: ModalHeaderBaseProps) {
|
||||
return (
|
||||
<HeaderShell
|
||||
{...props}
|
||||
railClassName="bg-brand"
|
||||
kickerClassName="text-stat-subtitle"
|
||||
variant="default"
|
||||
TitleComponent={DialogTitle}
|
||||
DescriptionComponent={DialogDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -85,8 +110,31 @@ export function ModalDestructiveHeader(props: ModalHeaderBaseProps) {
|
||||
return (
|
||||
<HeaderShell
|
||||
{...props}
|
||||
railClassName="bg-destructive"
|
||||
kickerClassName="text-destructive"
|
||||
variant="destructive"
|
||||
TitleComponent={DialogTitle}
|
||||
DescriptionComponent={DialogDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmHeader(props: ModalHeaderBaseProps) {
|
||||
return (
|
||||
<HeaderShell
|
||||
{...props}
|
||||
variant="default"
|
||||
TitleComponent={AlertDialogTitle}
|
||||
DescriptionComponent={AlertDialogDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDestructiveHeader(props: ModalHeaderBaseProps) {
|
||||
return (
|
||||
<HeaderShell
|
||||
{...props}
|
||||
variant="destructive"
|
||||
TitleComponent={AlertDialogTitle}
|
||||
DescriptionComponent={AlertDialogDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -120,3 +168,85 @@ export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFoote
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConfirmSize = 'sm' | 'md';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
size?: ConfirmSize;
|
||||
kicker: string;
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
hint?: React.ReactNode;
|
||||
confirmLabel: React.ReactNode;
|
||||
cancelLabel?: React.ReactNode;
|
||||
confirming?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
hint,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
confirming = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
}: ConfirmModalProps) {
|
||||
const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader;
|
||||
const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' });
|
||||
const actionClass = buttonVariants({
|
||||
variant: variant === 'destructive' ? 'destructive' : 'default',
|
||||
size: 'sm',
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className={cn('p-0 gap-0 overflow-hidden border-card-border/60', SIZE_CLASS[size])}>
|
||||
<Header kicker={kicker} title={title} description={description} />
|
||||
{children !== undefined && <ModalBody>{children}</ModalBody>}
|
||||
<ModalFooter
|
||||
hint={hint}
|
||||
secondary={
|
||||
<AlertDialogCancel
|
||||
className={cancelClass}
|
||||
disabled={confirming}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
primary={
|
||||
<AlertDialogAction
|
||||
className={actionClass}
|
||||
disabled={confirming}
|
||||
onClick={(e) => {
|
||||
const result = onConfirm();
|
||||
// Async confirms keep the dialog open so the caller can render
|
||||
// `confirming` state and close via onOpenChange when work completes.
|
||||
// Sync confirms let Radix auto-close.
|
||||
if (result instanceof Promise) {
|
||||
e.preventDefault();
|
||||
void result;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
}
|
||||
/>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user