Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/media-library-folder-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds flat-folder organization to the local Media Library. Editors can create, rename, and delete folders. Authors can organize their own local media, and editors can organize any local media, through Media Details or by dragging a media card or row onto a visible folder.

Uploads continue to enter the Main library. Deleting a folder returns its media to the Main library without deleting files or changing their URLs.
23 changes: 21 additions & 2 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ EmDash includes a media library for managing images, documents, and other files.

## Accessing the Media Library

Open the media library from the admin sidebar by clicking **Media**. The library displays all uploaded files with previews, filenames, and upload dates.
Open the media library from the admin sidebar by clicking **Media**. The Main library shows folders
and files that are not assigned to a folder. Open a folder to see its files.

<img src={mediaLibraryImg.src} alt="EmDash media library showing image grid with upload button" />

Expand All @@ -20,7 +21,7 @@ Open the media library from the admin sidebar by clicking **Media**. The library

1. Open **Media** in the admin sidebar.

2. Select **Upload to Library**, then **Browse files** to choose one or more files. You can also drag files anywhere onto the media library.
2. Select **Upload Files**, then **Browse files** to choose one or more files. You can also drag files anywhere onto the media library.

3. Uploads start automatically. The dialog shows each file's status and lets you cancel or retry individual files.

Expand Down Expand Up @@ -165,6 +166,24 @@ Use the search box to find files by name. Search matches partial filenames.

Use the type filter to show images, documents, video, or audio files.

## Organizing media in folders

Editors can select **Add new folder** from the Main library. Open a folder by selecting its name.
Without a search term, folder pages show only the media assigned to that folder. Filename searches
cover the whole library, including other folders and the Main library.

To move a local file into a visible folder, drag its grid card or list row onto the folder. You can
also open **Media Details**, choose a **Location**, and select **Save**. Use **Location** to return a
file to the Main library or to move it without dragging.

Authors can move local files they uploaded. Editors can move any local file. Files from external
providers cannot be assigned to folders.

Uploads enter the Main library. Move them into a folder after upload using either method above.

Deleting a folder returns its media to the Main library. The media files, URLs, and content
references remain unchanged.

## Using Media in Content

### In the Rich Text Editor
Expand Down
20 changes: 17 additions & 3 deletions docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -517,11 +517,25 @@ media requires `media:edit_any`. Bearer tokens also require the `media:write` sc
### List Media Folders

```http
GET /_emdash/api/media/folders?limit=50&cursor=...
GET /_emdash/api/media/folders?limit=50&q=product&cursor=...
```

Returns folders in name order with an optional `nextCursor`. `limit` accepts 1 to 100 and defaults
to 50. The endpoint requires `media:read`.
| Parameter | Type | Description |
| --------- | -------- | ----------------------------------------------------- |
| `cursor` | `string` | Opaque pagination cursor |
| `limit` | `number` | Folders per page, from 1 to 100 (default: 50) |
| `q` | `string` | Case-insensitive partial folder-name search (1–200 characters) |

Returns folders in name order with an optional `nextCursor`. The endpoint requires `media:read`.

### Get Media Folder

```http
GET /_emdash/api/media/folders/:id
```

Returns the folder with the requested ID. The endpoint requires `media:read` and returns 404 when
the folder does not exist.

### Create Media Folder

Expand Down
194 changes: 189 additions & 5 deletions e2e/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import AxeBuilder from "@axe-core/playwright";
import type { Locator, Page } from "@playwright/test";

import { test, expect } from "../fixtures";

Expand All @@ -23,6 +24,27 @@ const SETTINGS_URL = /\/settings\/?(?:[?#].*)?$/;
// to panel IDs that may not be in the DOM when collapsed (kumo Sidebar collapsible groups)
const KNOWN_A11Y_EXCLUSIONS = ["color-contrast", "aria-valid-attr-value"];

async function beginPointerDrag(page: Page, source: Locator, target: Locator) {
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
if (!sourceBox || !targetBox) throw new Error("Drag source or target is not visible");
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(
sourceBox.x + sourceBox.width / 2 + 12,
sourceBox.y + sourceBox.height / 2,
{
steps: 2,
},
);
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 8,
});
await page.locator("[data-media-drag-overlay]").waitFor();
await page.mouse.move(targetBox.x + targetBox.width / 2 + 1, targetBox.y + targetBox.height / 2);
await expect(target).toHaveAttribute("data-drop-active", "true");
}

test.describe("Accessibility Audit", () => {
test.describe("Login Page", () => {
test("should have no WCAG 2.x AA violations", async ({ admin }) => {
Expand Down Expand Up @@ -178,12 +200,174 @@ test.describe("Accessibility Audit", () => {
await admin.waitForLoading();
await expect(admin.page).toHaveURL(MEDIA_URL);

const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
const analyze = () =>
new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect((await analyze()).violations).toEqual([]);

const folderName = `Accessibility ${Date.now()}`;
await admin.page.getByRole("button", { name: "Add new folder" }).click();
const folderDialog = admin.page.getByRole("dialog", { name: "Add new folder" });
expect((await analyze()).violations).toEqual([]);
await folderDialog.getByLabel("Name").fill(folderName);
await folderDialog.getByRole("button", { name: "Create" }).click();

await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
expect((await analyze()).violations).toEqual([]);
const editDialog = admin.page.getByRole("dialog", { name: "Edit folder" });
await editDialog.getByRole("button", { name: "Delete folder" }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.getByRole("button", { name: "Cancel" }).last().click();
await editDialog.getByRole("button", { name: "Cancel" }).click();

await admin.page.getByRole("link", { name: `Open folder ${folderName}` }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.getByRole("button", { name: "Back to Main library" }).first().click();

await admin.page.locator("[data-media-grid] button").first().click();
const mediaDetails = admin.page.getByRole("dialog", { name: "Media Details" });
await mediaDetails.getByRole("combobox", { name: "Location" }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.keyboard.press("Escape");
await mediaDetails.getByRole("button", { name: "Close" }).click();

await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
await editDialog.getByRole("button", { name: "Delete folder" }).click();
await admin.page
.getByRole("dialog", { name: `Delete “${folderName}”?` })
.getByRole("button", { name: "Delete folder" })
.click();
});

expect(results.violations).toEqual([]);
test("media list folder states should have no WCAG 2.x AA violations", async ({ admin }) => {
test.setTimeout(60_000);
const page = admin.page;
const folderPattern = "**/_emdash/api/media/folders?**";
let releaseFolders: () => void = () => {};
const folderGate = new Promise<void>((resolve) => {
releaseFolders = resolve;
});
await page.route(folderPattern, async (route) => {
if (route.request().method() !== "GET") return route.continue();
await folderGate;
await route.continue();
});

await admin.goToMedia();
await expect(page.getByRole("heading", { name: "Media Library" })).toBeVisible();
await page.getByRole("tab", { name: "List view" }).click();
const table = page.getByRole("table");
await expect(table.getByText("Loading folders")).toBeVisible();
const analyze = () =>
new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect((await analyze()).violations).toEqual([]);

releaseFolders();
await expect(table.getByText("Loading folders")).not.toBeVisible();
await page.unroute(folderPattern);
const folderName = `List accessibility ${Date.now()}`;
await page.getByRole("button", { name: "Add new folder" }).click();
const folderDialog = page.getByRole("dialog", { name: "Add new folder" });
await folderDialog.getByLabel("Name").fill(folderName);
await folderDialog.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toBeVisible();
expect((await analyze()).violations).toEqual([]);

await page.route(folderPattern, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const url = new URL(route.request().url());
if (url.searchParams.has("cursor")) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
success: false,
error: { code: "TEST_ERROR", message: "Folder list failed" },
}),
});
return;
}
const response = await route.fetch();
const body = (await response.json()) as { data: { nextCursor?: string } };
body.data.nextCursor = "forced-accessibility-page";
await route.fulfill({ response, json: body });
});
await page.reload();
const listTab = page.getByRole("tab", { name: "List view" });
if ((await listTab.getAttribute("aria-selected")) !== "true") await listTab.click();
await page.getByRole("button", { name: "Load more folders" }).click();
await expect(table.getByRole("alert")).toHaveText("Folders could not be loaded.");
await expect(table.getByRole("button", { name: "Retry" })).toBeVisible();
expect((await analyze()).violations).toEqual([]);

await page.unroute(folderPattern);
await page.reload();
await page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
const editDialog = page.getByRole("dialog", { name: "Edit folder" });
await editDialog.getByRole("button", { name: "Delete folder" }).click();
await page
.getByRole("dialog", { name: `Delete “${folderName}”?` })
.getByRole("button", { name: "Delete folder" })
.click();
});

test("media drag target and failure feedback should have no WCAG 2.x AA violations", async ({
admin,
}) => {
test.setTimeout(60_000);
const page = admin.page;
await page.setViewportSize({ width: 1512, height: 982 });
const folderName = `Drag accessibility ${Date.now()}`;
await admin.goToMedia();
await admin.waitForLoading();
await page.getByRole("button", { name: "Add new folder" }).click();
const createDialog = page.getByRole("dialog", { name: "Add new folder" });
await createDialog.getByLabel("Name").fill(folderName);
await createDialog.getByRole("button", { name: "Create" }).click();
await page.reload();
await admin.waitForLoading();
const source = page.locator("[data-media-grid] > [data-media-draggable]").first();
const target = page.locator("[data-media-folder-card]").filter({ hasText: folderName });
const analyze = () =>
new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();

await beginPointerDrag(page, source, target);
expect((await analyze()).violations).toEqual([]);
await page.keyboard.press("Escape");
await page.mouse.up();

await page.route("**/_emdash/api/media/**", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
success: false,
error: { code: "MOVE_FAILED", message: "Move failed" },
}),
});
});
await beginPointerDrag(page, source, target);
await page.mouse.up();
await expect(page.getByText("Couldn’t move file", { exact: true })).toBeVisible();
await expect(page.getByText("Try again.", { exact: true })).toBeVisible();
expect((await analyze()).violations).toEqual([]);
await page.unroute("**/_emdash/api/media/**");

await page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
const editDialog = page.getByRole("dialog", { name: "Edit folder" });
await editDialog.getByRole("button", { name: "Delete folder" }).click();
const confirmDelete = page.getByRole("dialog", { name: `Delete “${folderName}”?` });
await confirmDelete.getByRole("button", { name: "Delete folder" }).click();
await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toHaveCount(0);
});

test("users page should have no WCAG 2.x AA violations", async ({ admin }) => {
Expand Down
22 changes: 13 additions & 9 deletions e2e/tests/image-optimization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,25 @@ test.describe("image optimization", () => {
request,
}) => {
const img = page.locator("figure.emdash-image img").first();
let src: string | null = null;
let lastError: unknown;

// The workerd dev runner's Vite dep optimizer can transiently 500 a cold
// route even after warm-up; reload until the page renders. (Dev-only; the
// deployed Worker has no optimizer.)
for (let attempt = 0; attempt < 5; attempt++) {
await page.goto("/posts/post-with-image");
if (await img.isVisible().catch(() => false)) break;
await page.waitForTimeout(1000);
try {
await page.goto("/posts/post-with-image");
await expect(img).toBeVisible();
await expect(img).toHaveJSProperty("naturalWidth", 1);
src = await img.getAttribute("src");
if (src) break;
} catch (error) {
lastError = error;
}
if (attempt < 4) await page.waitForTimeout(1000);
}
await expect(img).toBeVisible();
await expect
.poll(() => img.evaluate((element) => (element as HTMLImageElement).naturalWidth))
.toBe(1);

const src = await img.getAttribute("src");
if (!src && lastError) throw lastError;
expect(src, "image src should be optimized via Astro's image endpoint").toContain("/_image");

// The optimized URL must return real image bytes, not an Access redirect or 404.
Expand Down
Loading
Loading