Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<adapterId>/<storagePath>` for signed redirects.
Expand Down
144 changes: 144 additions & 0 deletions src/__tests__/media/minimizeWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FloatingWindow
panelId="mediaUploadQueue"
open
title="Uploads"
defaultPosition={{ x: 16, y: 80 }}
minimizable={minimizable}
headerActions={<button type="button">Clear finished</button>}
onClose={() => {}}
>
<p>Three files uploading</p>
</FloatingWindow>,
)
}

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(
<AdminSessionContext value={session as never}>
<MediaViewerWindow
editor={editor as unknown as Parameters<typeof MediaViewerWindow>[0]['editor']}
open
onClose={() => {}}
/>
</AdminSessionContext>,
)
}

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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps
return (
<FloatingWindow
panelId="mediaBulkEdit"
minimizable
open={open}
onClose={onClose}
title={`Bulk edit · ${count} selected`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
overflow: hidden;
}

/* Collapsed: shrink to the header. The height above is a fixed length, so
hiding the body alone left the window at full size with an empty pane
under the title bar. `height: auto` sizes it to the header that remains,
while `top` above keeps clamping against the FULL height — which is what
stops a window collapsed near the bottom edge from jumping. */
.window[data-minimized="true"] {
height: auto;
}

.body {
flex: 1 1 auto;
display: grid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { useCurrentAdminUser } from '@admin/sessionContext'
import { Copy2SolidIcon } from 'pixel-art-icons/icons/copy-2-solid'
import { ExternalLinkSolidIcon } from 'pixel-art-icons/icons/external-link-solid'
import { ReloadIcon } from 'pixel-art-icons/icons/reload'
import { MinusIcon } from 'pixel-art-icons/icons/minus'
import { PlusIcon } from 'pixel-art-icons/icons/plus'
import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid'
import { VideoSolidIcon } from 'pixel-art-icons/icons/video-solid'
import { PanelHeader } from '@admin/shared/PanelHeader'
Expand Down Expand Up @@ -101,6 +103,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
const currentUser = useCurrentAdminUser()
const { asset } = editor
const [replaceOpen, setReplaceOpen] = useState(false)
const [minimized, setMinimized] = useState(false)
const bucket = bucketForMime(asset.mimeType)
const canWrite = canWriteMedia(currentUser)
const canReplace = canReplaceMedia(currentUser)
Expand Down Expand Up @@ -164,20 +167,38 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
<aside
ref={setPanelRef}
className={styles.window}
data-minimized={minimized ? 'true' : undefined}
role="dialog"
aria-label={`Viewer: ${asset.filename}`}
data-testid="media-viewer-window"
style={panelPositionStyle}
onClick={(event) => 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. */}
<PanelHeader
panelId="mediaDetachedInspector"
title={asset.filename}
onClose={onClose}
dragHandleProps={headerDragProps}
/>
>
<Button
variant="ghost"
size="xs"
iconOnly
tooltip={minimized ? 'Expand' : 'Minimize'}
aria-label={minimized ? `Expand ${asset.filename}` : `Minimize ${asset.filename}`}
aria-expanded={!minimized}
data-testid="panel-minimize-mediaDetachedInspector"
onClick={() => setMinimized((value) => !value)}
>
{minimized ? <PlusIcon size={13} /> : <MinusIcon size={13} />}
</Button>
</PanelHeader>

<div className={styles.body}>
{!minimized && <div className={styles.body}>
<div className={styles.viewer}>
<ViewerBody asset={asset} />
</div>
Expand Down Expand Up @@ -338,7 +359,7 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {
</Section>
)}
</aside>
</div>
</div>}

{canReplace && (
<ReplaceFileDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function UploadQueueWindow({ queue, open, onClose, onRevealAsset }: Uploa
return (
<FloatingWindow
panelId="mediaUploadQueue"
minimizable
open={open}
onClose={onClose}
title={`Uploads (${succeeded}/${total})`}
Expand Down
42 changes: 37 additions & 5 deletions src/admin/shared/FloatingWindow/FloatingWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useImperativeHandle, type CSSProperties, type ReactNode, type Ref } from 'react'
import { useImperativeHandle, useState, type CSSProperties, type ReactNode, type Ref } from 'react'
import { createPortal } from 'react-dom'
import { PanelHeader } from '@admin/shared/PanelHeader'
import { Button } from '@ui/components/Button'
import { MinusIcon } from 'pixel-art-icons/icons/minus'
import { PlusIcon } from 'pixel-art-icons/icons/plus'
import type { FloatingPanelId, PanelPosition } from '@admin/state/workspaceLayoutStorage'
import { cn } from '@ui/cn'
import { useDraggablePanel } from './useDraggablePanel'
Expand All @@ -19,6 +22,18 @@ interface FloatingWindowProps {
bodyClassName?: string
ariaLabel?: string
testId?: string
/**
* Offer a collapse control beside Close, leaving only the title bar.
*
* For windows that stay useful while the user works around them — an upload
* in flight, a bulk edit mid-review — where the choice is otherwise between
* losing the panel and letting it cover the grid.
*
* Collapsed state is per-session and stays where the window sits: the
* position is the user's own, so folding to a corner would discard it, and
* expanding would then have nowhere honest to return to.
*/
minimizable?: boolean
onClose(): void
children?: ReactNode
ref?: Ref<HTMLDivElement>
Expand All @@ -42,6 +57,7 @@ export function FloatingWindow({
bodyClassName,
ariaLabel,
testId,
minimizable = false,
onClose,
children,
ref: forwardedRef,
Expand All @@ -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

Expand All @@ -78,9 +96,23 @@ export function FloatingWindow({
onClose={onClose}
dragHandleProps={headerDragProps}
>
{headerActions}
{minimized ? null : headerActions}
{minimizable && (
<Button
variant="ghost"
size="xs"
iconOnly
tooltip={minimized ? 'Expand' : 'Minimize'}
aria-label={minimized ? `Expand ${title}` : `Minimize ${title}`}
aria-expanded={!minimized}
data-testid={`panel-minimize-${panelId}`}
onClick={() => setMinimized((value) => !value)}
>
{minimized ? <PlusIcon size={13} /> : <MinusIcon size={13} />}
</Button>
)}
</PanelHeader>
<div className={cn(styles.body, bodyClassName)}>{children}</div>
{!minimized && <div className={cn(styles.body, bodyClassName)}>{children}</div>}
</aside>,
document.body,
)
Expand Down
Loading