mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 11:47:01 +00:00
fix(whats-new): add RBAC entry and a zoomable screenshot lightbox (#1806)
RBAC is the largest capability change in this release, so it leads the list. Every entry's screenshot now opens full-viewport on click; the overlay is portaled to document.body so it isn't capped at the modal's own frame, dismissible via backdrop click, a close button, or Escape.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ExternalLink, X } from 'lucide-react';
|
||||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { whatsNewEntries } from '@/whats-new/entries';
|
import { whatsNewEntries } from '@/whats-new/entries';
|
||||||
@@ -23,11 +24,20 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
|
|||||||
// not-yet-added filename is a realistic mistake. Drop the image instead of
|
// not-yet-added filename is a realistic mistake. Drop the image instead of
|
||||||
// leaving the browser's broken-image placeholder in the card.
|
// leaving the browser's broken-image placeholder in the card.
|
||||||
const [failedScreenshots, setFailedScreenshots] = useState<Set<string>>(new Set());
|
const [failedScreenshots, setFailedScreenshots] = useState<Set<string>>(new Set());
|
||||||
|
const [zoomedSrc, setZoomedSrc] = useState<string | null>(null);
|
||||||
|
const closeZoom = useCallback(() => setZoomedSrc(null), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) markSeen();
|
if (open) markSeen();
|
||||||
}, [open, markSeen]);
|
}, [open, markSeen]);
|
||||||
|
|
||||||
|
// WhatsNewModal stays mounted for the app's lifetime (EditorLayout renders it
|
||||||
|
// with a controlled `open`), so without this reset a lightbox left open at
|
||||||
|
// close time would resurface on the next open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) closeZoom();
|
||||||
|
}, [open, closeZoom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// xl (max-w-xl w-[95vw]), not the md default (max-w-md): cards carry
|
// xl (max-w-xl w-[95vw]), not the md default (max-w-md): cards carry
|
||||||
// screenshots and need more width than the default confirm-dialog size.
|
// screenshots and need more width than the default confirm-dialog size.
|
||||||
@@ -46,13 +56,19 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
|
|||||||
<h3 className="text-sm font-medium text-stat-value">{entry.title}</h3>
|
<h3 className="text-sm font-medium text-stat-value">{entry.title}</h3>
|
||||||
<p className="text-sm leading-relaxed text-stat-subtitle">{entry.blurb}</p>
|
<p className="text-sm leading-relaxed text-stat-subtitle">{entry.blurb}</p>
|
||||||
{entry.screenshot && !failedScreenshots.has(entry.id) && (
|
{entry.screenshot && !failedScreenshots.has(entry.id) && (
|
||||||
<img
|
<button
|
||||||
src={`/whats-new/${entry.screenshot}`}
|
type="button"
|
||||||
alt={entry.title}
|
onClick={() => setZoomedSrc(`/whats-new/${entry.screenshot}`)}
|
||||||
className="rounded-md border border-card-border/60"
|
aria-label={`Zoom in on ${entry.title} screenshot`}
|
||||||
loading="lazy"
|
className="block w-full cursor-zoom-in overflow-hidden rounded-md border border-card-border/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
|
>
|
||||||
/>
|
<img
|
||||||
|
src={`/whats-new/${entry.screenshot}`}
|
||||||
|
alt={entry.title}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{entry.docUrl && (
|
{entry.docUrl && (
|
||||||
<a href={entry.docUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}>
|
<a href={entry.docUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}>
|
||||||
@@ -89,6 +105,54 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{zoomedSrc && <ScreenshotLightbox src={zoomedSrc} onClose={closeZoom} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScreenshotLightboxProps {
|
||||||
|
src: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScreenshotLightbox({ src, onClose }: ScreenshotLightboxProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Capture phase, ahead of Radix's own document-level capture listener for
|
||||||
|
// the parent Dialog's Escape handling: stopping propagation here is what
|
||||||
|
// keeps Escape from also dismissing the whole modal while zoomed.
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Zoomed screenshot"
|
||||||
|
onClick={onClose}
|
||||||
|
// Radix sets an inline pointer-events: none on <body> while the parent
|
||||||
|
// Dialog is open (to keep interaction scoped to its own content); this
|
||||||
|
// portal renders as a body child too, so it needs an explicit inline
|
||||||
|
// override (a Tailwind class here would be inert: nothing overrides an
|
||||||
|
// ancestor's inline style except another inline style).
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
className="fixed inset-0 z-[60] flex cursor-zoom-out items-center justify-center bg-[var(--scrim)] p-8 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<img src={src} alt="" className="max-h-full max-w-full rounded-md object-contain" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close zoomed screenshot"
|
||||||
|
className="absolute right-4 top-4 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-popover/80 text-popover-foreground hover:bg-popover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,4 +87,48 @@ describe('WhatsNewModal', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: 'View full changelog' }));
|
await userEvent.click(screen.getByRole('button', { name: 'View full changelog' }));
|
||||||
expect(onViewChangelog).toHaveBeenCalledTimes(1);
|
expect(onViewChangelog).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clicking a screenshot opens a zoomed overlay with the same image', async () => {
|
||||||
|
render(<WhatsNewModal open onOpenChange={vi.fn()} onViewChangelog={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
|
||||||
|
const zoomed = screen.getByRole('dialog', { name: 'Zoomed screenshot' });
|
||||||
|
expect(zoomed.querySelector('img')).toHaveAttribute('src', '/whats-new/second.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the close button dismisses only the zoom overlay, leaving the parent modal open', async () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Close zoomed screenshot' }));
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
|
||||||
|
// The regression this guards against: an earlier implementation portaled
|
||||||
|
// the overlay to document.body, outside Radix's Dialog content subtree,
|
||||||
|
// so Radix treated every zoom-dismiss click as an outside click and also
|
||||||
|
// closed the parent. If that regressed, onOpenChange(false) fires here.
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the backdrop dismisses only the zoom overlay, leaving the parent modal open', async () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
|
||||||
|
await userEvent.click(screen.getByRole('dialog', { name: 'Zoomed screenshot' }));
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape dismisses only the zoom overlay, leaving the parent modal open', async () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(<WhatsNewModal open onOpenChange={onOpenChange} onViewChangelog={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Zoom in on Second feature screenshot' }));
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('dialog', { name: 'Zoomed screenshot' })).not.toBeInTheDocument();
|
||||||
|
// Radix's own Escape handling for the parent Dialog is a document-level
|
||||||
|
// capture listener; without stopPropagation this fires too and also
|
||||||
|
// requests the parent close.
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('dialog', { name: "What's New" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,5 +40,12 @@
|
|||||||
"blurb": "Drag the divider in the Files tab to give the tree pane more room, or shrink it out of the way. Your preferred width is remembered.",
|
"blurb": "Drag the divider in the Files tab to give the tree pane more room, or shrink it out of the way. Your preferred width is remembered.",
|
||||||
"docUrl": "https://docs.sencho.io/features/stack-file-explorer",
|
"docUrl": "https://docs.sencho.io/features/stack-file-explorer",
|
||||||
"screenshot": "file-explorer.png"
|
"screenshot": "file-explorer.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rbac",
|
||||||
|
"title": "Role-based access control",
|
||||||
|
"blurb": "Every instance ships with five built-in roles, Admin, Viewer, Deployer, Node Admin, and Auditor, plus scoped permissions to grant access to one stack or node without elevating anywhere else.",
|
||||||
|
"docUrl": "https://docs.sencho.io/features/rbac",
|
||||||
|
"screenshot": "rbac.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user