mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
e8f271f5f6
* feat(ui): make the core stack flow usable on mobile Below the md breakpoint the app collapses to a single full-width column: the stack list is full-screen, tapping a stack opens a full-screen detail with a Health / Logs / Compose segmented control (Logs first) and a back button, and a bottom tab bar switches Stacks, Fleet, Schedules, and Settings. Compose is read-only on a phone with a prompt to edit on desktop. Desktop (md and up) is unchanged: the mobile shell is gated behind a useIsMobile hook plus max-md/md variants, and the stack-detail blocks are shared with the desktop two-pane view so it renders identically. Also generalizes the unsaved-changes guard so leaving a dirty editor (back, tab bar, hamburger) prompts before discarding; adds 44px touch targets on list rows, filter chips, and actions; makes log and shell modals full-screen on mobile; and offsets toasts and the deploy pill above the bottom tab bar. * fix(ui): keep mobile nav in sync when opening views from outside the bottom bar On a phone the sidebar activity actions, the node switcher's Manage Nodes, the profile Settings entry, and the dashboard configuration links set the active view without flipping the mobile surface to content, so the user stayed on the stack list and never saw the destination. Route these through the mobile-aware navigation and settings helpers (a no-op on desktop).
261 lines
7.1 KiB
TypeScript
261 lines
7.1 KiB
TypeScript
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]';
|
|
|
|
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'wide';
|
|
|
|
const SIZE_CLASS: Record<ModalSize, string> = {
|
|
sm: 'max-w-sm',
|
|
md: 'max-w-md',
|
|
lg: 'max-w-lg',
|
|
xl: 'max-w-xl w-[95vw]',
|
|
wide: 'max-w-5xl w-[95vw]',
|
|
};
|
|
|
|
interface ModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
children: React.ReactNode;
|
|
size?: ModalSize;
|
|
className?: string;
|
|
/** Pass through to DialogContent. Set to false when the modal renders its own close affordance. */
|
|
showClose?: boolean;
|
|
/** Fill the viewport below md (for large overlays like logs / shell). Opt-in so
|
|
* small confirm dialogs keep their compact centered size on a phone. */
|
|
mobileFullScreen?: boolean;
|
|
}
|
|
|
|
export function Modal({ open, onOpenChange, children, size = 'md', className, showClose, mobileFullScreen }: ModalProps) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showClose={showClose}
|
|
className={cn(
|
|
'p-0 gap-0 overflow-hidden grid-cols-1',
|
|
SIZE_CLASS[size],
|
|
className,
|
|
mobileFullScreen && 'max-md:h-[100dvh] max-md:w-screen max-md:max-w-none max-md:rounded-none max-md:border-0',
|
|
)}
|
|
>
|
|
{children}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface ModalHeaderBaseProps {
|
|
kicker: string;
|
|
title: React.ReactNode;
|
|
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,
|
|
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]', v.rail)} />
|
|
<div className={cn(KICKER_CLASS, 'whitespace-nowrap', v.kicker)}>
|
|
{kicker}
|
|
</div>
|
|
<TitleComponent className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
|
{title}
|
|
</TitleComponent>
|
|
<DescriptionComponent className="sr-only">
|
|
{description ?? (typeof title === 'string' ? title : kicker)}
|
|
</DescriptionComponent>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ModalHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
variant="default"
|
|
TitleComponent={DialogTitle}
|
|
DescriptionComponent={DialogDescription}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ModalDestructiveHeader(props: ModalHeaderBaseProps) {
|
|
return (
|
|
<HeaderShell
|
|
{...props}
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ModalBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
return <div className={cn('px-6 py-5 space-y-4 max-h-[calc(85vh-12rem)] overflow-y-auto', className)} {...props} />;
|
|
}
|
|
|
|
interface ModalFooterProps {
|
|
primary: React.ReactNode;
|
|
secondary?: React.ReactNode;
|
|
hint?: React.ReactNode;
|
|
hintAccent?: React.ReactNode;
|
|
}
|
|
|
|
export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFooterProps) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-4 border-t border-card-border/60 px-6 py-4">
|
|
<div className={cn(KICKER_CLASS, 'text-stat-subtitle')}>
|
|
{hint}
|
|
{hintAccent !== undefined && (
|
|
<span className="ml-1.5 rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-stat-value">
|
|
{hintAccent}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{secondary}
|
|
{primary}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|