mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +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 { ExternalLink } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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
|
||||
// leaving the browser's broken-image placeholder in the card.
|
||||
const [failedScreenshots, setFailedScreenshots] = useState<Set<string>>(new Set());
|
||||
const [zoomedSrc, setZoomedSrc] = useState<string | null>(null);
|
||||
const closeZoom = useCallback(() => setZoomedSrc(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (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 (
|
||||
// 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.
|
||||
@@ -46,13 +56,19 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
|
||||
<h3 className="text-sm font-medium text-stat-value">{entry.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-stat-subtitle">{entry.blurb}</p>
|
||||
{entry.screenshot && !failedScreenshots.has(entry.id) && (
|
||||
<img
|
||||
src={`/whats-new/${entry.screenshot}`}
|
||||
alt={entry.title}
|
||||
className="rounded-md border border-card-border/60"
|
||||
loading="lazy"
|
||||
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedSrc(`/whats-new/${entry.screenshot}`)}
|
||||
aria-label={`Zoom in on ${entry.title} screenshot`}
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src={`/whats-new/${entry.screenshot}`}
|
||||
alt={entry.title}
|
||||
loading="lazy"
|
||||
onError={() => setFailedScreenshots((prev) => new Set(prev).add(entry.id))}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{entry.docUrl && (
|
||||
<a href={entry.docUrl} target="_blank" rel="noopener noreferrer" className={linkClassName}>
|
||||
@@ -89,6 +105,54 @@ export function WhatsNewModal({ open, onOpenChange, onViewChangelog }: WhatsNewM
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{zoomedSrc && <ScreenshotLightbox src={zoomedSrc} onClose={closeZoom} />}
|
||||
</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' }));
|
||||
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.",
|
||||
"docUrl": "https://docs.sencho.io/features/stack-file-explorer",
|
||||
"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