diff --git a/docs/features/media.md b/docs/features/media.md index 3c1483ee3..90e87c396 100644 --- a/docs/features/media.md +++ b/docs/features/media.md @@ -13,7 +13,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter - **State:** one hook — `useMediaWorkspace()` — orchestrates folders, assets, selection, filters, upload queue, and folder moves. The editor store doesn't grow new slices; the Media page is self-contained. - **Folders:** folders render as first-class grid/list items in the canvas. Opening a folder filters the canvas to its contents, and nested folders show a parent-folder entry to navigate back. - **Drag/drop:** assets can be dragged into folders from the canvas or folder tree. A drop replaces the asset's folder memberships with the target folder, matching desktop file-manager move semantics. -- **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. +- **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. All three can fold to their title bar — `minimizable` on `FloatingWindow`; the viewer builds its own shell and wires the same control, with `data-minimized` collapsing its fixed height. Minimized state is per-session, and a folded window stays where it sits: the position is the user's own. - **Auto-open behavior:** upload queue opens when uploads start; bulk-edit opens at 2+ selected; viewer opens on primary selection. - **Server side:** `media_assets`, `media_folders`, `media_asset_folders` tables. Handlers under `/admin/api/cms/media`, `/admin/api/cms/media/folders`, `/admin/api/cms/media/storage`. Repositories at `server/repositories/media*.ts`. - **Storage adapters:** built-in local-disk plus plugin-registered adapters. Non-public-url adapters route through `/_instatic/media//` for signed redirects. diff --git a/src/__tests__/media/minimizeWindow.test.tsx b/src/__tests__/media/minimizeWindow.test.tsx new file mode 100644 index 000000000..fff6970ce --- /dev/null +++ b/src/__tests__/media/minimizeWindow.test.tsx @@ -0,0 +1,144 @@ +/** + * Minimizing a floating window instead of dismissing it. + * + * The upload queue mid-transfer, a bulk edit mid-review, the viewer beside + * the grid: closing loses the panel, keeping it covers the grid. A + * minimizable window folds to its title bar and comes back where it was. + * + * These render the real windows and press the real control. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MediaViewerWindow } from '@admin/pages/media/components/MediaViewerWindow/MediaViewerWindow' +import { AdminSessionContext } from '@admin/sessionContext' +import { FloatingWindow } from '@admin/shared/FloatingWindow' + +afterEach(cleanup) + +function renderWindow(minimizable: boolean) { + render( + Clear finished} + onClose={() => {}} + > +

Three files uploading

+
, + ) +} + +describe('a minimizable FloatingWindow', () => { + it('folds to its title bar', () => { + renderWindow(true) + fireEvent.click(screen.getByRole('button', { name: 'Minimize Uploads' })) + + expect(screen.queryByText('Three files uploading')).toBeNull() + // Header actions act on the body, so they fold with it. + expect(screen.queryByRole('button', { name: 'Clear finished' })).toBeNull() + // The title bar stays: it is what the user drags and expands from. + expect(screen.getByText('Uploads')).toBeTruthy() + }) + + it('says which state it is in', () => { + renderWindow(true) + const control = screen.getByRole('button', { name: 'Minimize Uploads' }) + expect(control.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(control) + expect(screen.getByRole('button', { name: 'Expand Uploads' }).getAttribute('aria-expanded')).toBe('false') + }) + + it('comes back with its content when expanded', () => { + renderWindow(true) + fireEvent.click(screen.getByRole('button', { name: 'Minimize Uploads' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand Uploads' })) + + expect(screen.getByText('Three files uploading')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Clear finished' })).toBeTruthy() + }) + + it('offers no control unless the window opts in', () => { + renderWindow(false) + expect(screen.queryByRole('button', { name: /minimize/i })).toBeNull() + expect(screen.getByText('Three files uploading')).toBeTruthy() + }) +}) + +describe('the media viewer window', () => { + // It builds its own shell rather than FloatingWindow, so it wires the + // control itself and needs its own check. + function renderViewer() { + const editor = { + asset: { + id: 'asset_1', + filename: 'logo.png', + mimeType: 'image/png', + sizeBytes: 1200, + publicPath: '/uploads/logo.png', + uploadedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + altText: '', + caption: '', + title: '', + tags: [], + width: null, + height: null, + durationMs: null, + dominantColor: null, + deletedAt: null, + replacedAt: null, + folderIds: [], + blurHash: null, + variants: [], + posterPath: null, + }, + tagPalette: [], + folderById: new Map(), + updateAsset: async () => undefined, + renameAsset: async () => undefined, + replaceAssetFile: async () => undefined, + restoreAsset: async () => undefined, + purgeAsset: async () => undefined, + } + const session = { + user: { id: 'u1', capabilities: ['media.write', 'media.delete'] }, + setUser: () => {}, + } + render( + + [0]['editor']} + open + onClose={() => {}} + /> + , + ) + } + + it('folds to its title bar and collapses its height with it', () => { + renderViewer() + expect(screen.getByRole('textbox', { name: 'Alt text' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Minimize logo.png' })) + + expect(screen.queryByRole('textbox', { name: 'Alt text' })).toBeNull() + // The window's height is a fixed length; hiding the body alone left a + // full-size empty pane under the title bar. The attribute is what the + // stylesheet collapses it on. + const viewer = screen.getByRole('dialog', { name: 'Viewer: logo.png' }) + expect(viewer.getAttribute('data-minimized')).toBe('true') + }) + + it('comes back when expanded', () => { + renderViewer() + fireEvent.click(screen.getByRole('button', { name: 'Minimize logo.png' })) + fireEvent.click(screen.getByRole('button', { name: 'Expand logo.png' })) + + expect(screen.getByRole('textbox', { name: 'Alt text' })).toBeTruthy() + expect(screen.getByRole('dialog', { name: 'Viewer: logo.png' }).hasAttribute('data-minimized')).toBe(false) + }) +}) diff --git a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx index 496894e19..d5f98bcbf 100644 --- a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx +++ b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx @@ -186,6 +186,7 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps return ( event.stopPropagation()} > + {/* This window builds its own shell rather than `FloatingWindow`, so the + collapse control is wired here directly — same behaviour, same + per-session scope, same reason for staying put: the position is the + user's own and folding to a corner would discard it. */} + > + + -
+ {!minimized &&
@@ -338,7 +359,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) { )} -
+
} {canReplace && ( @@ -42,6 +57,7 @@ export function FloatingWindow({ bodyClassName, ariaLabel, testId, + minimizable = false, onClose, children, ref: forwardedRef, @@ -52,12 +68,14 @@ export function FloatingWindow({ ) useImperativeHandle(forwardedRef, () => panelRef.current as HTMLDivElement) + const [minimized, setMinimized] = useState(false) + if (!open) return null const style = { '--floating-window-w': cssLength(width), - '--floating-window-h': cssLength(height), - '--floating-window-max-h': cssLength(maxHeight), + '--floating-window-h': minimized ? 'auto' : cssLength(height), + '--floating-window-max-h': minimized ? 'none' : cssLength(maxHeight), ...panelPositionStyle, } as CSSProperties @@ -78,9 +96,23 @@ export function FloatingWindow({ onClose={onClose} dragHandleProps={headerDragProps} > - {headerActions} + {minimized ? null : headerActions} + {minimizable && ( + + )} -
{children}
+ {!minimized &&
{children}
} , document.body, )