diff --git a/.changeset/media-library-folder-ui.md b/.changeset/media-library-folder-ui.md new file mode 100644 index 0000000000..3bcc209e23 --- /dev/null +++ b/.changeset/media-library-folder-ui.md @@ -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. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 94b681bb20..3069fc6672 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -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. EmDash media library showing image grid with upload button @@ -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. @@ -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 diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 9ca698f97b..008fd96ed1 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -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 diff --git a/e2e/tests/accessibility.spec.ts b/e2e/tests/accessibility.spec.ts index 0f0b81f233..42e602b367 100644 --- a/e2e/tests/accessibility.spec.ts +++ b/e2e/tests/accessibility.spec.ts @@ -6,6 +6,7 @@ */ import AxeBuilder from "@axe-core/playwright"; +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../fixtures"; @@ -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 }) => { @@ -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((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 }) => { diff --git a/e2e/tests/image-optimization.spec.ts b/e2e/tests/image-optimization.spec.ts index c71e44d715..144d8bb859 100644 --- a/e2e/tests/image-optimization.spec.ts +++ b/e2e/tests/image-optimization.spec.ts @@ -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. diff --git a/e2e/tests/invite-flow.spec.ts b/e2e/tests/invite-flow.spec.ts index 98645cd4ab..2d1e3ca8ea 100644 --- a/e2e/tests/invite-flow.spec.ts +++ b/e2e/tests/invite-flow.spec.ts @@ -24,7 +24,7 @@ import { expect, test } from "../fixtures"; import { addVirtualWebAuthnAuthenticator } from "../fixtures/virtual-authenticator"; // Regex patterns -const ADMIN_URL_PATTERN = /\/_emdash\/admin/; +const ADMIN_URL_PATTERN = /\/_emdash\/admin\/?$/; const INVITE_URL_REGEX = /https?:\/\/[^\s]+\/admin\/invite\/accept\?token=[^\s]+/; const URL_IN_TEXT_REGEX = /https?:\/\/[^\s]+/; @@ -184,8 +184,6 @@ test.describe("Invite creation via API", () => { }); test.describe("Full invite flow with passkey registration", () => { - test.describe.configure({ mode: "serial" }); - test("completes invite registration with virtual authenticator", async ({ admin, page }) => { test.setTimeout(120_000); @@ -211,10 +209,18 @@ test.describe("Full invite flow with passkey registration", () => { const nameInput = page.getByLabel("Your name (optional)"); await nameInput.fill("Invited User"); + const completionResponsePromise = page + .waitForResponse((response) => { + return new URL(response.url()).pathname === "/_emdash/api/auth/invite/complete"; + }) + .then((response) => response.status()); await page.getByRole("button", { name: "Create Account" }).click(); + expect(await completionResponsePromise).toBe(200); // Step 6: Wait for passkey flow to complete and redirect await expect(page).toHaveURL(ADMIN_URL_PATTERN, { timeout: 60_000 }); + await admin.waitForShell(); + await expect(page.getByRole("button", { name: /Invited User/ })).toBeVisible(); // Verify no passkey errors appeared await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0); @@ -223,30 +229,4 @@ test.describe("Full invite flow with passkey registration", () => { await removeAuth(); } }); - - test("invited user appears in the users list", async ({ admin, page }) => { - // FIXME(cloudflare): on the Cloudflare/workerd target the invited user is - // not visible in the users list after the passkey-invite registration - // above — reproducible in isolation, passes on Node. The registration - // ceremony completes (redirects to admin, no errors) but the user row - // isn't read back. NOT a consistency artifact: miniflare D1 is a local - // SQLite file (strongly consistent), so if the read misses a committed - // write it's a real code bug — likely a SQLite-vs-D1-dialect difference in - // the invite-registration write or the user-list read. Needs a - // browser-driven repro (passkey-gated) to confirm backend vs front-end. - // Flagged for maintainers as possibly a real Cloudflare bug; skipped so - // the CF lane stays green. - test.skip( - process.env.EMDASH_E2E_TARGET === "cloudflare", - "CF: invited user not read back after passkey-invite registration (possible real bug, under investigation)", - ); - await admin.devBypassAuth(); - await admin.goto("/users"); - await admin.waitForShell(); - await admin.waitForLoading(); - - await expect(page.locator("text=invited-user@example.com")).toBeVisible({ - timeout: 15000, - }); - }); }); diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index d9e169f37c..fdead07267 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -5,10 +5,10 @@ * Runs against an isolated fixture — starts with no media. */ -import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../fixtures"; @@ -42,7 +42,7 @@ function ensureTestAssets(): string { return testImagePath; } -async function uploadTestImage(page: Page) { +async function uploadTestImage(page: Page, filename?: string) { const testImagePath = ensureTestAssets(); await page.getByRole("button", { name: UPLOAD_BUTTON_REGEX }).first().click(); const dialog = page.getByRole("dialog"); @@ -55,13 +55,62 @@ async function uploadTestImage(page: Page) { res.status() === 200, { timeout: 10000 }, ); - await dialog.getByLabel(BROWSE_FILES_LABEL).setInputFiles(testImagePath); + await dialog.getByLabel(BROWSE_FILES_LABEL).setInputFiles( + filename + ? { + name: filename, + mimeType: "image/png", + buffer: Buffer.concat([readFileSync(testImagePath), Buffer.from(filename)]), + } + : testImagePath, + ); await uploadResponse; await expect(dialog.getByText("Complete", { exact: true })).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); await expect(dialog).not.toBeVisible(); } +async function createFolder(page: Page, name: string) { + await page.getByRole("button", { name: "Add new folder" }).click(); + const dialog = page.getByRole("dialog", { name: "Add new folder" }); + await dialog.getByLabel("Name").fill(name); + await dialog.getByRole("button", { name: "Create" }).click(); + await expect(dialog).not.toBeVisible(); +} + +async function pointerDrag(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"); + const sourcePoint = { + x: sourceBox.x + sourceBox.width / 2, + y: sourceBox.y + sourceBox.height / 2, + }; + const targetPoint = { + x: targetBox.x + targetBox.width / 2, + y: targetBox.y + targetBox.height / 2, + }; + await page.mouse.move(sourcePoint.x, sourcePoint.y); + await page.mouse.down(); + await page.mouse.move(sourcePoint.x + 12, sourcePoint.y, { steps: 2 }); + await page.mouse.move(targetPoint.x, targetPoint.y, { steps: 8 }); + const overlayCard = page.locator("[data-media-drag-overlay] > *"); + await overlayCard.waitFor(); + const overlayBox = await overlayCard.boundingBox(); + if (!overlayBox) throw new Error("Drag overlay is not visible"); + expect(Math.abs(overlayBox.x + overlayBox.width / 2 - targetPoint.x)).toBeLessThanOrEqual(4); + expect(Math.abs(overlayBox.y + overlayBox.height / 2 - targetPoint.y)).toBeLessThanOrEqual(4); + await page.mouse.up(); +} + +async function expectFolderColumns(cards: Locator, expectedColumns: number) { + await expect(cards).toHaveCount(4); + const columnCount = await cards.first().evaluate((element) => { + return getComputedStyle(element.parentElement!).gridTemplateColumns.trim().split(/\s+/).length; + }); + expect(columnCount).toBe(expectedColumns); +} + test.describe("Media Library", () => { test.beforeAll(() => { ensureTestAssets(); @@ -143,5 +192,557 @@ test.describe("Media Library", () => { await expect(page.locator("th:has-text('Type')")).toBeVisible(); await expect(page.locator("th:has-text('Size')")).toBeVisible(); }); + + test("keeps bounded folder states inside the mixed table", async ({ admin, page }) => { + test.setTimeout(60_000); + let releaseFolders: () => void = () => {}; + const folderGate = new Promise((resolve) => { + releaseFolders = resolve; + }); + const folderPattern = "**/_emdash/api/media/folders?**"; + 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"); + const loadingRow = table.getByRole("row").filter({ hasText: "Loading folders" }); + await expect(loadingRow).toBeVisible(); + await expect(loadingRow.locator("td")).toHaveAttribute("colspan", "5"); + + releaseFolders(); + await expect(loadingRow).not.toBeVisible(); + await page.unroute(folderPattern); + const folderName = `List folder ${Date.now()}`; + await createFolder(page, folderName); + + 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: "Later folder page failed" }, + }), + }); + return; + } + const response = await route.fetch(); + const body = (await response.json()) as { + data: { nextCursor?: string }; + }; + body.data.nextCursor = "forced-next-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 expect(page.getByRole("heading", { name: "Folders" })).toHaveCount(0); + const folderLink = page.getByRole("link", { name: `Open folder ${folderName}` }); + const editFolder = page.getByRole("button", { name: `Edit folder ${folderName}` }); + await expect(folderLink).toBeVisible(); + const folderLinkBox = await folderLink.boundingBox(); + const editFolderBox = await editFolder.boundingBox(); + expect(folderLinkBox).not.toBeNull(); + expect(editFolderBox).not.toBeNull(); + expect(editFolderBox!.x - (folderLinkBox!.x + folderLinkBox!.width)).toBeLessThanOrEqual(8); + + await page.getByRole("button", { name: "Load more folders" }).click(); + const rows = table.locator("tbody > tr"); + await expect(table.getByRole("alert")).toHaveText("Folders could not be loaded."); + await page.setViewportSize({ width: 320, height: 800 }); + const retryBox = await table.getByRole("button", { name: "Retry" }).boundingBox(); + expect(retryBox).not.toBeNull(); + expect(retryBox!.x + retryBox!.width).toBeLessThanOrEqual(320); + const rowText = await rows.allTextContents(); + const folderIndex = rowText.findIndex((text) => text.includes(folderName)); + const errorIndex = rowText.findIndex((text) => text.includes("Folders could not be loaded.")); + const loadMoreIndex = rowText.findIndex((text) => text.includes("Load more folders")); + expect(folderIndex).toBeGreaterThanOrEqual(0); + expect(errorIndex).toBeGreaterThan(folderIndex); + expect(loadMoreIndex).toBeGreaterThan(errorIndex); + expect(loadMoreIndex).toBeLessThan(rowText.length - 1); + 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(); + const confirmDelete = page.getByRole("dialog", { name: `Delete “${folderName}”?` }); + await confirmDelete.getByRole("button", { name: "Delete folder" }).click(); + await expect(confirmDelete).not.toBeVisible(); + await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toHaveCount(0); + }); + }); + + test("moves one local media item into a visible folder by dragging", async ({ admin, page }) => { + test.setTimeout(90_000); + await page.setViewportSize({ width: 1512, height: 982 }); + await admin.goToMedia(); + await admin.waitForLoading(); + const uniqueFilename = `drag-source-${Date.now()}.png`; + await uploadTestImage(page, uniqueFilename); + + const gridFolderName = `Grid drop ${Date.now()}`; + await createFolder(page, gridFolderName); + const grid = page.locator("[data-media-grid]"); + const gridSource = grid.locator(":scope > button").filter({ hasText: uniqueFilename }); + const originalImage = gridSource.locator("img"); + await expect(originalImage).toBeVisible(); + const originalSrc = await originalImage.getAttribute("src"); + const sourceFilename = await gridSource.locator("p").textContent(); + expect(originalSrc).not.toBeNull(); + expect(sourceFilename).not.toBeNull(); + const originalMediaUrl = + new URL(originalSrc!, page.url()).searchParams.get("href") ?? originalSrc!; + const originalImageSelector = `img[src=${JSON.stringify(originalSrc)}]`; + const gridTarget = page + .getByRole("link", { name: `Open folder ${gridFolderName}` }) + .locator("xpath=ancestor::*[@data-media-folder-card][1]"); + const gridMoveResponse = page.waitForResponse( + (response) => + response.request().method() === "PUT" && + new URL(response.url()).pathname.includes("/_emdash/api/media/") && + response.status() === 200, + ); + + await pointerDrag(page, gridSource, gridTarget); + await gridMoveResponse; + await expect(page).toHaveURL(/\/_emdash\/admin\/media\/?$/); + await expect(page.getByRole("dialog", { name: "Media Details" })).toHaveCount(0); + await expect(grid.locator(originalImageSelector)).toHaveCount(0); + await page.getByRole("link", { name: `Open folder ${gridFolderName}` }).click(); + await expect(grid.locator(originalImageSelector)).toBeVisible(); + const movedGridSrc = await grid.locator(originalImageSelector).getAttribute("src"); + expect(new URL(movedGridSrc!, page.url()).searchParams.get("href") ?? movedGridSrc).toBe( + originalMediaUrl, + ); + await page.getByRole("link", { name: "Back" }).click(); + await page.getByRole("button", { name: `Edit folder ${gridFolderName}` }).click(); + let editDialog = page.getByRole("dialog", { name: "Edit folder" }); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + let confirmDelete = page.getByRole("dialog", { name: `Delete “${gridFolderName}”?` }); + await confirmDelete.getByRole("button", { name: "Delete folder" }).click(); + await expect(grid.locator(originalImageSelector)).toBeVisible(); + + const listFolderName = `List drop ${Date.now()}`; + await createFolder(page, listFolderName); + await page.getByRole("tab", { name: "List view" }).click(); + const table = page.getByRole("table"); + const listSource = table.getByRole("row").filter({ hasText: sourceFilename! }); + const listTarget = page + .getByRole("link", { name: `Open folder ${listFolderName}` }) + .locator("xpath=ancestor::tr[1]"); + const listMoveResponse = page.waitForResponse( + (response) => + response.request().method() === "PUT" && + new URL(response.url()).pathname.includes("/_emdash/api/media/") && + response.status() === 200, + ); + + await pointerDrag(page, listSource, listTarget); + await listMoveResponse; + await expect(page).toHaveURL(/\/_emdash\/admin\/media\/?$/); + await expect(table.getByRole("row").filter({ hasText: sourceFilename! })).toHaveCount(0); + await page.getByRole("link", { name: `Open folder ${listFolderName}` }).click(); + const movedListRow = table.getByRole("row").filter({ hasText: sourceFilename! }); + await expect(movedListRow).toBeVisible(); + const movedListSrc = await movedListRow.locator("img").getAttribute("src"); + expect(new URL(movedListSrc!, page.url()).searchParams.get("href") ?? movedListSrc).toBe( + originalMediaUrl, + ); + await page.getByRole("link", { name: "Back" }).click(); + await page.getByRole("button", { name: `Edit folder ${listFolderName}` }).click(); + editDialog = page.getByRole("dialog", { name: "Edit folder" }); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + confirmDelete = page.getByRole("dialog", { name: `Delete “${listFolderName}”?` }); + await confirmDelete.getByRole("button", { name: "Delete folder" }).click(); + await expect(table.getByRole("row").filter({ hasText: sourceFilename! })).toBeVisible(); + + await table.getByRole("row").filter({ hasText: sourceFilename! }).click(); + const details = page.getByRole("dialog", { name: "Media Details" }); + await details.getByRole("button", { name: "Delete" }).click(); + const confirmMediaDelete = page.getByRole("dialog", { name: "Delete Media?" }); + await confirmMediaDelete.getByRole("button", { name: "Delete" }).click(); + await expect(details).not.toBeVisible(); + await expect(table.getByRole("row").filter({ hasText: sourceFilename! })).toHaveCount(0); + }); + + test("matches the compact responsive folder layout and mixed-direction names", async ({ + admin, + page, + }) => { + test.setTimeout(60_000); + const folderMarker = crypto.randomUUID(); + const longFolderName = `Campaign assets with a deliberately long folder name ${folderMarker}`; + await admin.goToMedia(); + await admin.waitForLoading(); + await createFolder(page, `Archive ${folderMarker}`); + await createFolder(page, longFolderName); + await createFolder(page, `Events ${folderMarker}`); + await createFolder(page, `Press ${folderMarker}`); + const createdFolderCards = page + .locator("[data-media-folder-card]") + .filter({ hasText: folderMarker }); + + const folderIconContrast = () => + page + .locator("[data-media-folder-card] svg") + .first() + .evaluate((element) => { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas context unavailable"); + const toRgb = (color: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data; + }; + const luminance = (rgb: Uint8ClampedArray) => { + const toLinear = (channel: number) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return ( + 0.2126 * toLinear(rgb[0] ?? 0) + + 0.7152 * toLinear(rgb[1] ?? 0) + + 0.0722 * toLinear(rgb[2] ?? 0) + ); + }; + const foreground = luminance(toRgb(getComputedStyle(element).color)); + const background = luminance( + toRgb(getComputedStyle(element.parentElement!).backgroundColor), + ); + return ( + (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05) + ); + }); + + await page.evaluate(() => localStorage.setItem("emdash-theme", "light")); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-mode", "light"); + expect(await folderIconContrast()).toBeGreaterThanOrEqual(3); + await page.evaluate(() => localStorage.setItem("emdash-theme", "dark")); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-mode", "dark"); + expect(await folderIconContrast()).toBeGreaterThanOrEqual(3); + await page.evaluate(() => localStorage.setItem("emdash-theme", "system")); + await page.reload(); + + for (const [width, columns] of [ + [640, 1], + [800, 2], + [1100, 3], + [1400, 4], + ] as const) { + await page.setViewportSize({ width, height: 900 }); + await expectFolderColumns(createdFolderCards, columns); + } + + await page.setViewportSize({ width: 1512, height: 982 }); + expect( + await page + .locator("[data-media-folder-card]") + .first() + .evaluate((element) => element.getBoundingClientRect().height), + ).toBeLessThanOrEqual(72); + const hoverCard = page.locator("[data-media-folder-card]").first(); + const hoverLink = hoverCard.getByRole("link"); + const hoverEdit = hoverCard.getByRole("button", { name: /Edit folder/ }); + const initialFolderBackground = await hoverCard.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + await hoverLink.hover(); + const hoverState = await hoverCard.evaluate((element) => ({ + background: getComputedStyle(element).backgroundColor, + linkBackground: getComputedStyle(element.querySelector("a")!).backgroundColor, + })); + expect(hoverState.background).not.toBe(initialFolderBackground); + expect(hoverState.linkBackground).toBe("rgba(0, 0, 0, 0)"); + await hoverEdit.hover(); + expect( + await hoverEdit.evaluate((button) => { + const rect = button.getBoundingClientRect(); + const top = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + return top === button || button.contains(top); + }), + ).toBe(true); + + await page.setViewportSize({ width: 320, height: 800 }); + const mediaTitleBox = await page + .getByRole("heading", { name: "Media Library", level: 1 }) + .boundingBox(); + const addFolderBox = await page.getByRole("button", { name: "Add new folder" }).boundingBox(); + const uploadFilesBox = await page.getByRole("button", { name: "Upload Files" }).boundingBox(); + const searchBox = await page.getByRole("searchbox", { name: "Search media" }).boundingBox(); + const typeFilterBox = await page + .getByRole("combobox", { name: "Filter by type" }) + .boundingBox(); + const viewModeBox = await page.getByRole("group", { name: "View mode" }).boundingBox(); + expect(mediaTitleBox).not.toBeNull(); + expect(addFolderBox).not.toBeNull(); + expect(uploadFilesBox).not.toBeNull(); + expect(searchBox).not.toBeNull(); + expect(typeFilterBox).not.toBeNull(); + expect(viewModeBox).not.toBeNull(); + expect(addFolderBox!.width).toBeGreaterThan(44); + expect(addFolderBox!.width).toBeLessThanOrEqual(90); + expect(uploadFilesBox!.width).toBeGreaterThan(44); + expect(uploadFilesBox!.width).toBeLessThanOrEqual(72); + const centerY = (box: { y: number; height: number }) => box.y + box.height / 2; + expect(Math.abs(centerY(mediaTitleBox!) - centerY(addFolderBox!))).toBeLessThanOrEqual(1); + expect(Math.abs(centerY(mediaTitleBox!) - centerY(uploadFilesBox!))).toBeLessThanOrEqual(1); + expect(Math.abs(centerY(searchBox!) - centerY(typeFilterBox!))).toBeLessThanOrEqual(1); + expect(Math.abs(centerY(searchBox!) - centerY(viewModeBox!))).toBeLessThanOrEqual(1); + expect(searchBox!.x + searchBox!.width).toBeLessThan(typeFilterBox!.x); + expect(typeFilterBox!.x + typeFilterBox!.width).toBeLessThan(viewModeBox!.x); + const mediaGridBox = await page.locator("[data-media-grid]").boundingBox(); + const mediaCardBox = await page.locator("[data-media-grid] > button").first().boundingBox(); + expect(mediaGridBox).not.toBeNull(); + expect(mediaCardBox).not.toBeNull(); + expect(Math.abs(mediaGridBox!.width - mediaCardBox!.width)).toBeLessThanOrEqual(1); + + await page.getByRole("button", { name: "Add new folder" }).click(); + const createDialog = page.getByRole("dialog", { name: "Add new folder" }); + const createCancelBox = await createDialog + .getByRole("button", { name: "Cancel" }) + .boundingBox(); + const createSubmitBox = await createDialog + .getByRole("button", { name: "Create" }) + .boundingBox(); + expect(createCancelBox).not.toBeNull(); + expect(createSubmitBox).not.toBeNull(); + expect(createCancelBox!.y).not.toBe(createSubmitBox!.y); + expect(Math.abs(createCancelBox!.width - createSubmitBox!.width)).toBeLessThanOrEqual(1); + await createDialog.getByRole("button", { name: "Cancel" }).click(); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + const rtlSearch = page.locator('input[type="search"]'); + await rtlSearch.fill(longFolderName); + const longFolderText = page + .locator('[data-media-folder-card] [dir="auto"]') + .filter({ hasText: longFolderName }); + await expect(longFolderText).toBeVisible(); + const bidiMetrics = await longFolderText.evaluate((element) => ({ + direction: getComputedStyle(element).direction, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + text: element.textContent ?? "", + })); + expect(bidiMetrics.direction).toBe("ltr"); + expect(bidiMetrics.scrollWidth).toBeGreaterThan(bidiMetrics.clientWidth); + expect(bidiMetrics.text.startsWith("Campaign assets")).toBe(true); + + await longFolderText.locator("xpath=ancestor::a[1]").click(); + await expect(page).toHaveURL(/\/media\?folder=/); + const currentBidiName = page.locator('[aria-current="page"] [dir="auto"]').first(); + await expect(currentBidiName).toBeVisible(); + const currentBidiMetrics = await currentBidiName.evaluate((element) => ({ + direction: getComputedStyle(element).direction, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + text: element.textContent ?? "", + })); + expect(currentBidiMetrics.direction).toBe("ltr"); + expect(currentBidiMetrics.scrollWidth).toBeGreaterThan(currentBidiMetrics.clientWidth); + expect(currentBidiMetrics.text.startsWith("Campaign assets")).toBe(true); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "en", domain: "localhost", path: "/_emdash" }]); + }); + + test("keeps media intact while organizing it in folders", async ({ admin, page }) => { + test.setTimeout(90_000); + const folderName = `Product photos ${Date.now()}`; + const renamedFolder = `${folderName} archive`; + const uniqueFilename = `organize-${Date.now()}.png`; + const searchTerm = uniqueFilename.replace(/\.png$/, ""); + await admin.goToMedia(); + await admin.waitForLoading(); + await uploadTestImage(page, uniqueFilename); + await createFolder(page, folderName); + await createFolder(page, `Press ${Date.now()}`); + + const mediaGrid = page.locator("[data-media-grid]"); + const originalImage = mediaGrid.locator("img").first(); + await expect(originalImage).toBeVisible(); + const originalSrc = await originalImage.getAttribute("src"); + await mediaGrid.locator("button").first().click(); + + const details = page.getByRole("dialog", { name: "Media Details" }); + await details.getByRole("combobox", { name: "Location" }).click(); + await page.getByRole("option", { name: folderName }).click(); + await details.getByRole("button", { name: "Save" }).click(); + await expect(details).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeFocused(); + + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(page).toHaveURL(/\/media\?folder=/); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + + const folderSearch = page.getByRole("searchbox", { name: "Search media" }); + await folderSearch.fill(searchTerm); + const filteredMediaResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith("/_emdash/api/media") && url.searchParams.get("mimeType") === "image/" + ); + }); + await page.getByRole("combobox", { name: "Filter by type" }).click(); + await page.getByRole("option", { name: "Images" }).click(); + await filteredMediaResponse; + await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); + await page.getByRole("tab", { name: "List view" }).click(); + const pageSizeResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname.endsWith("/_emdash/api/media") && url.searchParams.get("limit") === "70"; + }); + await page.getByRole("combobox", { name: "Page size" }).click(); + await page.getByRole("option", { name: "70" }).click(); + await pageSizeResponse; + await expect(page.getByRole("combobox", { name: "Page size" })).toContainText("70"); + await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); + const main = page.locator("main"); + const headerBack = page.getByRole("link", { name: "Back" }); + await headerBack.focus(); + const scrollFixture = await page.addStyleTag({ + content: "main { padding-bottom: 1200px !important; }", + }); + const scrollBeforeBack = await main.evaluate((element) => { + element.scrollTop = 400; + return element.scrollTop; + }); + expect(scrollBeforeBack).toBeGreaterThan(0); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/media\/?$/); + await expect(folderSearch).toHaveValue(searchTerm); + await expect(page.getByRole("combobox", { name: "Filter by type" })).toContainText("Images"); + await expect(page.getByRole("combobox", { name: "Page size" })).toContainText("70"); + await expect(page.getByRole("tab", { name: "List view" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeFocused(); + const expectedScrollAfterBack = await main.evaluate((element, previousScroll) => { + return Math.min(previousScroll, element.scrollHeight - element.clientHeight); + }, scrollBeforeBack); + expect(expectedScrollAfterBack).toBeGreaterThan(0); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(expectedScrollAfterBack); + await scrollFixture.evaluate((element) => element.remove()); + + await folderSearch.fill(""); + await page.getByRole("combobox", { name: "Filter by type" }).click(); + await page.getByRole("option", { name: "All types" }).click(); + await page.getByRole("tab", { name: "Grid view" }).click(); + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(page).toHaveURL(/\/media\?folder=/); + await page.setViewportSize({ width: 320, height: 800 }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await page.reload(); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + await page.route("**/_emdash/api/media/folders?**", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1200)); + await route.continue(); + }); + const rootFoldersResponse = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith("/_emdash/api/media/folders"), + ); + const delayedBack = page.getByRole("link", { name: "Back" }); + await delayedBack.focus(); + const delayedScrollFixture = await page.addStyleTag({ + content: "main { padding-bottom: 1200px !important; }", + }); + const delayedScrollBeforeBack = await main.evaluate((element) => { + element.scrollTop = 400; + return element.scrollTop; + }); + expect(delayedScrollBeforeBack).toBeGreaterThan(0); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/media\/?$/); + await rootFoldersResponse; + const expectedDelayedScroll = await main.evaluate((element, previousScroll) => { + return Math.min(previousScroll, element.scrollHeight - element.clientHeight); + }, delayedScrollBeforeBack); + expect(expectedDelayedScroll).toBeGreaterThan(0); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(expectedDelayedScroll); + await delayedScrollFixture.evaluate((element) => element.remove()); + await page.unroute("**/_emdash/api/media/folders?**"); + await page.goBack(); + await expect(page).toHaveURL(/\/media\?folder=/); + await page.goForward(); + await expect(page).toHaveURL(/\/media\/?$/); + + const search = page.getByRole("searchbox", { name: "Search media" }); + await search.fill(folderName); + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(search).toHaveValue(""); + await search.fill(folderName); + await page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + + const editDialog = page.getByRole("dialog", { name: "Edit folder" }); + const editActionBoxes = await Promise.all( + ["Cancel", "Delete folder", "Save"].map((name) => + editDialog.getByRole("button", { name }).boundingBox(), + ), + ); + expect(editActionBoxes.every((box) => box !== null)).toBe(true); + expect(new Set(editActionBoxes.map((box) => box!.y)).size).toBe(3); + expect( + Math.max(...editActionBoxes.map((box) => box!.width)) - + Math.min(...editActionBoxes.map((box) => box!.width)), + ).toBeLessThanOrEqual(1); + await editDialog.getByLabel("Name").fill(renamedFolder); + await editDialog.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText(renamedFolder).first()).toBeVisible(); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await page + .context() + .addCookies([{ name: "emdash-locale", value: "en", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "ltr"); + + await search.fill(renamedFolder); + await page.getByRole("button", { name: `Edit folder ${renamedFolder}` }).click(); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + const confirm = page.getByRole("dialog", { name: `Delete “${renamedFolder}”?` }); + await confirm.getByRole("button", { name: "Delete folder" }).click(); + + await expect(page).toHaveURL(/\/media\/?$/); + await page.getByRole("button", { name: "Clear search" }).click(); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); }); }); diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 39398b499e..d0bada123f 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -5,29 +5,66 @@ * Opens when clicking an item in the MediaLibrary. */ -import { Button, ClipboardText, Dialog, Input, InputArea, Tooltip } from "@cloudflare/kumo"; +import { + Button, + ClipboardText, + Combobox, + Dialog, + Input, + InputArea, + Tooltip, + inputVariants, +} from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { X, Trash, Calendar, HardDrive, LinkSimple, Ruler, Info } from "@phosphor-icons/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + X, + Trash, + Calendar, + CaretDown, + HardDrive, + LinkSimple, + Ruler, + Info, +} from "@phosphor-icons/react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; -import { updateMedia, deleteMedia, deleteFromProvider, type MediaItem } from "../lib/api"; -import { useStableCallback } from "../lib/hooks"; +import { + ApiResponseError, + updateMedia, + deleteMedia, + deleteFromProvider, + fetchMediaFolder, + fetchMediaFolders, + fetchMediaItem, + type LocalMediaItem, + type MediaFolder, + type MediaItem, +} from "../lib/api"; +import { useDebouncedValue, useStableCallback } from "../lib/hooks"; import { getFileIcon, formatFileSize, metaPlayback } from "../lib/media-utils"; import { ConfirmDialog } from "./ConfirmDialog"; import { DialogError, getMutationError } from "./DialogError.js"; const CLOSE_FALLBACK_MS = 500; +interface MediaLocationOption { + id: string | null; + name: string; +} + export interface MediaDetailPanelProps { open: boolean; item: MediaItem; providerName?: string; canDelete?: boolean; + canMoveLocation?: boolean; restoreFocusTargetRef?: React.RefObject; onClose: () => void; onClosed?: () => void; onUpdated?: () => void; + onItemRefreshed?: (item: LocalMediaItem) => void; onDeleted?: () => void; } @@ -39,15 +76,18 @@ export function MediaDetailPanel({ item, providerName, canDelete: canDeleteProp, + canMoveLocation: canMoveLocationProp, restoreFocusTargetRef, onClose, onClosed, onUpdated, + onItemRefreshed, onDeleted, }: MediaDetailPanelProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const restoreFocusAfterDeleteRef = React.useRef(false); + const savePendingRef = React.useRef(false); const closeFallbackTimerRef = React.useRef(null); const closeFinishedRef = React.useRef(false); @@ -59,10 +99,16 @@ export function MediaDetailPanel({ const playback = metaPlayback(item.meta); const canEditMetadata = !isProviderAsset && isImage; const canDelete = !isProviderAsset || Boolean(canDeleteProp); + const localItem = isLocalMediaItem(item) ? item : null; + const canMoveLocation = Boolean(localItem && canMoveLocationProp); const [filename, setFilename] = React.useState(item.filename); const [alt, setAlt] = React.useState(item.alt ?? ""); const [caption, setCaption] = React.useState(item.caption ?? ""); + const [folderId, setFolderId] = React.useState(localItem?.folderId ?? null); + const [selectedFolder, setSelectedFolder] = React.useState(null); + const [locationOpen, setLocationOpen] = React.useState(false); + const [locationSearch, setLocationSearch] = React.useState(""); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [showDiscardConfirm, setShowDiscardConfirm] = React.useState(false); @@ -74,12 +120,17 @@ export function MediaDetailPanel({ } closeFinishedRef.current = false; restoreFocusAfterDeleteRef.current = false; + savePendingRef.current = false; setFilename(item.filename); setAlt(item.alt ?? ""); setCaption(item.caption ?? ""); + setFolderId(localItem?.folderId ?? null); + setSelectedFolder(null); + setLocationOpen(false); + setLocationSearch(""); setShowDeleteConfirm(false); setShowDiscardConfirm(false); - }, [item.id, open]); + }, [item.id, localItem?.folderId, open]); React.useEffect(() => { return () => { @@ -101,7 +152,7 @@ export function MediaDetailPanel({ onClosed?.(); if (shouldRestoreFocus) { window.setTimeout(() => { - restoreFocusTargetRef?.current?.focus(); + restoreFocusTargetRef?.current?.focus({ preventScroll: true }); }, 0); } }, [onClosed, restoreFocusTargetRef]); @@ -114,8 +165,11 @@ export function MediaDetailPanel({ closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS); }, [finishClose, onClose]); - const hasChanges = + const metadataChanged = canEditMetadata && (alt !== (item.alt ?? "") || caption !== (item.caption ?? "")); + const locationChanged = canMoveLocation && folderId !== localItem?.folderId; + const canEdit = canEditMetadata || canMoveLocation; + const hasChanges = metadataChanged || locationChanged; const isConfirmOpen = showDeleteConfirm || showDiscardConfirm; const publicFileUrl = !isProviderAsset && item.url ? new URL(item.url, window.location.origin).href : ""; @@ -123,14 +177,135 @@ export function MediaDetailPanel({ const filenameHelpLabel = t`Why can't this be changed?`; const altTextHelp = t`Used by screen readers and when image fails to load`; const altTextHelpLabel = t`Why is this important?`; + const debouncedLocationSearch = useDebouncedValue(locationSearch, 300); + const currentFolderQuery = useQuery({ + queryKey: ["media-folder", localItem?.folderId], + queryFn: () => fetchMediaFolder(localItem!.folderId!), + enabled: open && Boolean(localItem?.folderId), + retry: (failureCount, error) => + !(error instanceof ApiResponseError && error.code === "NOT_FOUND") && failureCount < 2, + }); + const currentFolderMissing = + currentFolderQuery.error instanceof ApiResponseError && + currentFolderQuery.error.code === "NOT_FOUND"; + const locationListQuery = useInfiniteQuery({ + queryKey: ["media-folders", "location", { search: debouncedLocationSearch.trim() }], + queryFn: ({ pageParam }) => + fetchMediaFolders({ + limit: 100, + cursor: pageParam, + search: debouncedLocationSearch.trim() || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open && canMoveLocation && locationOpen, + }); + const locationFolders = React.useMemo( + () => locationListQuery.data?.pages.flatMap((page) => page.items) ?? [], + [locationListQuery.data?.pages], + ); + const mainLocation = React.useMemo( + () => ({ id: null, name: t`Main library` }), + [t], + ); + const locationOptions = React.useMemo(() => { + const foldersById = new Map(); + for (const folder of locationFolders) foldersById.set(folder.id, folder); + if (currentFolderQuery.data && !currentFolderMissing) + foldersById.set(currentFolderQuery.data.id, currentFolderQuery.data); + if (selectedFolder) foldersById.set(selectedFolder.id, selectedFolder); + return [ + mainLocation, + ...[...foldersById.values()] + .toSorted( + (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + ) + .map((folder) => ({ id: folder.id, name: folder.name })), + ]; + }, [ + currentFolderMissing, + currentFolderQuery.data, + locationFolders, + mainLocation, + selectedFolder, + ]); + const selectedLocation = React.useMemo(() => { + if (folderId === null) return mainLocation; + return ( + locationOptions.find((option) => option.id === folderId) ?? { + id: folderId, + name: + currentFolderQuery.isLoading || currentFolderMissing + ? t`Loading...` + : t`Location unavailable`, + } + ); + }, [ + currentFolderMissing, + currentFolderQuery.isLoading, + folderId, + locationOptions, + mainLocation, + t, + ]); + const currentLocationName = + localItem?.folderId === null + ? mainLocation.name + : currentFolderMissing + ? t`Loading...` + : (currentFolderQuery.data?.name ?? + (currentFolderQuery.isLoading ? t`Loading...` : t`Location unavailable`)); + const recoveryPendingRef = React.useRef(false); + const recoveredFolderRef = React.useRef(null); + const recoverMediaMutation = useMutation({ + mutationFn: () => fetchMediaItem(item.id), + onSuccess: (refreshed) => { + onItemRefreshed?.(refreshed); + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + onError: () => { + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + onSettled: () => { + recoveryPendingRef.current = false; + }, + }); + const recoverMediaItem = useStableCallback(() => { + if (!localItem || recoveryPendingRef.current) return; + recoveryPendingRef.current = true; + recoverMediaMutation.mutate(); + }); + React.useEffect(() => { + recoveryPendingRef.current = false; + recoveredFolderRef.current = null; + recoverMediaMutation.reset(); + }, [item.id, localItem?.folderId]); + React.useEffect(() => { + if (!currentFolderMissing || !localItem?.folderId) return; + const recoveryKey = `${localItem.id}:${localItem.folderId}`; + if (recoveredFolderRef.current === recoveryKey) return; + recoveredFolderRef.current = recoveryKey; + recoverMediaItem(); + }, [currentFolderMissing, localItem?.folderId, localItem?.id, recoverMediaItem]); + React.useEffect(() => { + if (!open) recoveredFolderRef.current = null; + }, [open]); const updateMutation = useMutation({ - mutationFn: (data: { alt?: string; caption?: string }) => updateMedia(item.id, data), + mutationFn: (data: { alt?: string; caption?: string; folderId?: string | null }) => + updateMedia(item.id, data), onSuccess: () => { + if (locationChanged) restoreFocusAfterDeleteRef.current = true; void queryClient.invalidateQueries({ queryKey: ["media"] }); onUpdated?.(); closeDialog(); }, + onError: (error) => { + if (error instanceof ApiResponseError && error.code === "NOT_FOUND") recoverMediaItem(); + }, + onSettled: () => { + savePendingRef.current = false; + }, }); const deleteMutation = useMutation({ @@ -150,7 +325,22 @@ export function MediaDetailPanel({ }); const isSaving = updateMutation.isPending; const isDeleting = deleteMutation.isPending; - const isBusy = isSaving || isDeleting; + const isRecovering = recoverMediaMutation.isPending; + const mediaUnavailable = + recoverMediaMutation.error instanceof ApiResponseError && + recoverMediaMutation.error.code === "NOT_FOUND"; + const isBusy = isSaving || isDeleting || isRecovering; + const updateNotFound = + updateMutation.error instanceof ApiResponseError && updateMutation.error.code === "NOT_FOUND"; + const updateErrorMessage = mediaUnavailable + ? t`This media item no longer exists.` + : updateNotFound + ? isRecovering + ? null + : recoverMediaMutation.error + ? t`Couldn’t confirm whether the media item or selected folder still exists. Try again.` + : t`The selected folder no longer exists. Choose another location and save again.` + : getMutationError(updateMutation.error) || getMutationError(recoverMediaMutation.error); const requestClose = React.useCallback(() => { if (isBusy) return; @@ -163,10 +353,11 @@ export function MediaDetailPanel({ }, [closeDialog, hasChanges, isBusy, isConfirmOpen]); const handleSave = () => { - if (!canEditMetadata || !hasChanges || isSaving) return; + if (!canEdit || !hasChanges || isBusy || mediaUnavailable || savePendingRef.current) return; + savePendingRef.current = true; updateMutation.mutate({ - alt, - caption, + ...(canEditMetadata ? { alt, caption } : {}), + ...(locationChanged ? { folderId } : {}), }); }; @@ -187,7 +378,7 @@ export function MediaDetailPanel({ const handleKeyDown = (event: KeyboardEvent) => { if (isConfirmOpen) return; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { - if (!canEditMetadata || !hasChanges || isSaving) return; + if (!canEdit || !hasChanges || isBusy || mediaUnavailable) return; event.preventDefault(); stableHandleSave(); } @@ -195,7 +386,7 @@ export function MediaDetailPanel({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [canEditMetadata, hasChanges, isConfirmOpen, isSaving, open, stableHandleSave]); + }, [canEdit, hasChanges, isBusy, isConfirmOpen, mediaUnavailable, open, stableHandleSave]); return ( <> @@ -211,7 +402,7 @@ export function MediaDetailPanel({ >
+ {localItem && + (canMoveLocation ? ( + + label={t`Location`} + items={locationOptions} + filter={null} + value={selectedLocation} + inputValue={locationSearch} + isItemEqualToValue={(option, value) => option.id === value.id} + itemToStringLabel={(option) => option.name} + itemToStringValue={(option) => option.id ?? "main"} + disabled={isBusy || mediaUnavailable} + onOpenChange={(nextOpen) => { + setLocationOpen(nextOpen); + if (!nextOpen) setLocationSearch(""); + }} + onInputValueChange={(value, eventDetails) => { + if ( + eventDetails.reason === "input-change" || + eventDetails.reason === "input-clear" || + eventDetails.reason === "clear-press" + ) { + setLocationSearch(value); + } + }} + onValueChange={(option) => { + setFolderId(option?.id ?? null); + setSelectedFolder(option?.id ? { id: option.id, name: option.name } : null); + }} + > + + + {(option) => ( + {option?.name ?? t`Select a location`} + )} + + + + + + +
+ {locationListQuery.isFetching + ? t`Loading folders...` + : locationListQuery.data + ? plural(locationFolders.length, { + one: "# folder loaded", + other: "# folders loaded", + }) + : ""} +
+ {t`No folders found`} + + {(option) => ( + + {option.name} + + )} + + {locationListQuery.error && ( +
+

{t`Folders could not be loaded.`}

+ +
+ )} + {locationListQuery.hasNextPage && ( +
+ +
+ )} +
+ + ) : ( +
+

{t`Location`}

+

+ {currentLocationName} +

+
+ ))} + {canEditMetadata && ( <>
@@ -386,7 +692,7 @@ export function MediaDetailPanel({ value={alt} onChange={(event) => setAlt(event.target.value)} placeholder={t`Describe this image for accessibility`} - disabled={isSaving} + disabled={isBusy || mediaUnavailable} className="w-full" />
@@ -397,13 +703,13 @@ export function MediaDetailPanel({ onChange={(event) => setCaption(event.target.value)} placeholder={t`Optional caption for display`} rows={2} - disabled={isSaving} + disabled={isBusy || mediaUnavailable} /> )} - + @@ -419,7 +725,7 @@ export function MediaDetailPanel({ size="sm" icon={} onClick={handleDelete} - disabled={isBusy} + disabled={isBusy || mediaUnavailable} > {isDeleting ? t`Deleting...` : t`Delete`} @@ -427,14 +733,14 @@ export function MediaDetailPanel({
- {canEditMetadata && ( + {canEdit && ( @@ -484,4 +790,13 @@ function formatDate(isoString: string): string { }); } +function isLocalMediaItem(item: MediaItem): item is LocalMediaItem { + return ( + !item.provider && + "folderId" in item && + "authorId" in item && + typeof item.storageKey === "string" + ); +} + export default MediaDetailPanel; diff --git a/packages/admin/src/components/MediaFolderDialog.tsx b/packages/admin/src/components/MediaFolderDialog.tsx new file mode 100644 index 0000000000..3248ad866a --- /dev/null +++ b/packages/admin/src/components/MediaFolderDialog.tsx @@ -0,0 +1,193 @@ +import { Button, Dialog, Input, Toast } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import { ApiResponseError, type MediaFolder } from "../lib/api"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { DialogError, getMutationError } from "./DialogError.js"; + +export interface MediaFolderDialogProps { + open: boolean; + folder?: MediaFolder | null; + onClose: () => void; + onCreate: (name: string) => Promise; + onRename: (folder: MediaFolder, name: string) => Promise; + onDelete: (folder: MediaFolder) => Promise; +} + +export function MediaFolderDialog({ + open, + folder, + onClose, + onCreate, + onRename, + onDelete, +}: MediaFolderDialogProps) { + const { t } = useLingui(); + const toastManager = Toast.useToastManager(); + const [name, setName] = React.useState(""); + const [validationError, setValidationError] = React.useState(null); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const deleteButtonRef = React.useRef(null); + const savePendingRef = React.useRef(false); + const deletePendingRef = React.useRef(false); + const isEditing = folder !== null && folder !== undefined; + + React.useEffect(() => { + if (!open) return; + setName(folder?.name ?? ""); + setValidationError(null); + setDeleteOpen(false); + savePendingRef.current = false; + deletePendingRef.current = false; + }, [folder?.id, folder?.name, open]); + + const saveMutation = useMutation({ + mutationFn: (nextName: string) => (folder ? onRename(folder, nextName) : onCreate(nextName)), + onSuccess: () => { + toastManager.add({ + title: isEditing ? t`Folder successfully edited` : t`Folder successfully created`, + type: "success", + timeout: 3000, + }); + onClose(); + }, + onSettled: () => { + savePendingRef.current = false; + }, + }); + const deleteMutation = useMutation({ + mutationFn: () => { + if (!folder) throw new Error(t`Folder unavailable`); + return onDelete(folder); + }, + onSuccess: () => { + setDeleteOpen(false); + toastManager.add({ title: t`Folder deleted`, type: "success", timeout: 3000 }); + onClose(); + }, + onSettled: () => { + deletePendingRef.current = false; + }, + }); + React.useEffect(() => { + if (!open) return; + saveMutation.reset(); + deleteMutation.reset(); + }, [folder?.id, open]); + const isPending = saveMutation.isPending || deleteMutation.isPending; + const mutationError = saveMutation.error; + const fieldError = + mutationError instanceof ApiResponseError + ? mutationError.code === "VALIDATION_ERROR" + ? t`Folder name must be between 1 and 200 characters` + : mutationError.code === "CONFLICT" + ? t`A media folder with this name already exists` + : null + : null; + const dialogError = fieldError ? null : getMutationError(mutationError); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (isPending || savePendingRef.current) return; + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 200) { + setValidationError(t`Folder name must be between 1 and 200 characters`); + return; + } + setValidationError(null); + savePendingRef.current = true; + saveMutation.mutate(trimmed); + }; + const closeDelete = () => { + if (deleteMutation.isPending) return; + setDeleteOpen(false); + deleteMutation.reset(); + window.requestAnimationFrame(() => deleteButtonRef.current?.focus()); + }; + const confirmDelete = () => { + if (deletePendingRef.current || deleteMutation.isPending) return; + deletePendingRef.current = true; + deleteMutation.mutate(); + }; + + return ( + <> + { + if (!nextOpen && !isPending && !deleteOpen) onClose(); + }} + disablePointerDismissal={isPending} + > + +
+ + {isEditing ? t`Edit folder` : t`Add new folder`} + +
+ { + setName(event.target.value); + setValidationError(null); + saveMutation.reset(); + }} + error={validationError ?? fieldError ?? undefined} + autoFocus + disabled={isPending} + /> +
+ +
+ +
+ {folder && ( + + )} + +
+
+ +
+
+ {folder && ( + + )} + + ); +} diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index b01813188d..d45709c358 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -1,15 +1,57 @@ -import { Button, Input, Loader, Pagination, Select, Tabs } from "@cloudflare/kumo"; +import { + Breadcrumbs, + Button, + Grid, + Input, + LayerCard, + Loader, + Pagination, + Select, + Tabs, + Toasty, + createKumoToastManager, +} from "@cloudflare/kumo"; +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useDraggable, + useDroppable, + useSensor, + useSensors, + type CollisionDetection, + type DragCancelEvent, + type DragEndEvent, + type DragStartEvent, + type Modifier, +} from "@dnd-kit/core"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { Upload, Images, SquaresFour, List, MagnifyingGlass } from "@phosphor-icons/react"; +import { + ArrowLeft, + File as FileIcon, + Folder, + Images, + List, + MagnifyingGlass, + PencilSimple, + Plus, + SquaresFour, + Upload, +} from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { + type LocalMediaItem, type MediaItem, + type MediaFolder, type MediaUploadOptions, type MediaProviderItem, MEDIA_SEARCH_MAX_LENGTH, + ApiResponseError, fetchMediaProviders, fetchProviderMedia, uploadToProvider, @@ -26,7 +68,9 @@ import { } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; +import { MediaFolderDialog } from "./MediaFolderDialog.js"; import { LOCAL_MEDIA_UPLOAD_ACCEPT, MediaUploadDialog } from "./MediaUploadDialog.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ function mimeForTypeFilter(value: string): string | string[] | undefined { @@ -59,6 +103,26 @@ export interface MediaLibraryProps { onLocalSearchChange?: (q: string) => void; /** Called with the MIME filter for the local library (undefined = all types). */ onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; + /** Bounded folder pages owned by the main local Media route. */ + folders?: MediaFolder[]; + foldersLoading?: boolean; + foldersError?: Error | null; + hasMoreFolders?: boolean; + isLoadingMoreFolders?: boolean; + onLoadMoreFolders?: () => void; + onActiveProviderChange?: (providerId: string) => void; + folderId?: string; + currentFolder?: MediaFolder | null; + currentFolderLoading?: boolean; + canManageFolders?: boolean; + onOpenFolder?: (folder: MediaFolder) => void; + onBackToMain?: () => void; + onRetryFolders?: () => void; + onCreateFolder?: (name: string) => Promise; + onRenameFolder?: (folder: MediaFolder, name: string) => Promise; + onDeleteFolder?: (folder: MediaFolder) => Promise; + canMoveMedia?: (item: LocalMediaItem) => boolean; + onMoveMedia?: (item: LocalMediaItem, folder: MediaFolder) => Promise; } export interface MediaLibraryPagination { @@ -72,6 +136,51 @@ export interface MediaLibraryPagination { const MEDIA_PAGE_SIZE_OPTIONS = [35, 70, 90]; const MAX_DROPDOWN_PAGE_COUNT = 100; +const MEDIA_DRAG_OVERLAY_MAX_WIDTH = 384; +const MEDIA_DRAG_OVERLAY_HEIGHT = 36; +let pendingMediaLibraryScrollTop: number | null = null; + +interface MediaDragData { + kind: "local-media"; + item: LocalMediaItem; +} + +interface MediaFolderTargetData { + kind: "media-folder-target"; + folder: MediaFolder; +} + +const mediaDragId = (id: string) => `media:${id}`; +const folderDropId = (id: string) => `folder:${id}`; + +function isMediaDragData(value: unknown): value is MediaDragData { + return (value as MediaDragData | undefined)?.kind === "local-media"; +} + +function isFolderTargetData(value: unknown): value is MediaFolderTargetData { + return (value as MediaFolderTargetData | undefined)?.kind === "media-folder-target"; +} + +const centerMediaOverlayOnCursor: Modifier = ({ + activatorEvent, + draggingNodeRect, + transform, + windowRect, +}) => { + if (!activatorEvent || !draggingNodeRect || !("clientX" in activatorEvent)) return transform; + const pointer = activatorEvent as PointerEvent; + const previewWidth = Math.min( + draggingNodeRect.width, + MEDIA_DRAG_OVERLAY_MAX_WIDTH, + (windowRect?.width ?? MEDIA_DRAG_OVERLAY_MAX_WIDTH) - 32, + ); + return { + ...transform, + x: transform.x + pointer.clientX - draggingNodeRect.left - previewWidth / 2, + y: transform.y + pointer.clientY - draggingNodeRect.top - MEDIA_DRAG_OVERLAY_HEIGHT / 2, + }; +}; +const MEDIA_DRAG_OVERLAY_MODIFIERS = [centerMediaOverlayOnCursor]; /** * Media library component with upload, provider tabs, and grid view @@ -86,8 +195,28 @@ export function MediaLibrary({ pagination, onLocalSearchChange, onLocalMimeFilterChange, + onActiveProviderChange, + folders = [], + foldersLoading, + foldersError, + hasMoreFolders, + isLoadingMoreFolders, + onLoadMoreFolders, + folderId, + currentFolder, + currentFolderLoading, + canManageFolders, + onOpenFolder, + onBackToMain, + onRetryFolders, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + canMoveMedia, + onMoveMedia, }: MediaLibraryProps) { const { t } = useLingui(); + const [toastManager] = React.useState(createKumoToastManager); const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); const [detailItem, setDetailItem] = React.useState(null); const [isDetailOpen, setIsDetailOpen] = React.useState(false); @@ -118,6 +247,13 @@ export function MediaLibrary({ const enqueueIdRef = React.useRef(0); const dragDepthRef = React.useRef(0); const returnFocusRef = React.useRef(null); + const [folderDialogOpen, setFolderDialogOpen] = React.useState(false); + const [editingFolder, setEditingFolder] = React.useState(null); + const folderDialogReturnFocusRef = React.useRef(null); + const [activeDragItem, setActiveDragItem] = React.useState(null); + const suppressDragClickRef = React.useRef(false); + const suppressDragClickTimerRef = React.useRef(null); + const movePendingRef = React.useRef(false); // Track loaded image dimensions for providers that don't return them (e.g., CF Images) const [loadedDimensions, setLoadedDimensions] = React.useState< Record @@ -158,6 +294,7 @@ export function MediaLibrary({ }, [activeProvider, providers, t]); const canUpload = activeProviderInfo?.capabilities.upload ?? false; const canSearch = activeProviderInfo?.capabilities.search ?? false; + const canUploadHere = canUpload && (activeProvider !== "local" || !folderId); const cancelPendingDetailOpen = React.useCallback(() => { if (detailOpenFrameRef.current === null) return; @@ -166,7 +303,6 @@ export function MediaLibrary({ }, []); React.useEffect(() => cancelPendingDetailOpen, [cancelPendingDetailOpen]); - const requestPage = React.useCallback( (nextPage: number) => { if (!pagination || pagination.isPending) return; @@ -238,20 +374,23 @@ export function MediaLibrary({ const handleDetailClosed = React.useCallback(() => { setDetailItem(null); }, []); + const handleDetailItemRefreshed = React.useCallback((refreshed: LocalMediaItem) => { + setDetailItem((current) => (current?.id === refreshed.id ? refreshed : current)); + }, []); const enqueueFiles = React.useCallback( (files: readonly File[], returnFocus?: HTMLElement | null) => { - if (!canUpload || !activeProviderInfo || files.length === 0) return; + if (!canUploadHere || !activeProviderInfo || files.length === 0) return; if (returnFocus) returnFocusRef.current = returnFocus; setUploadTarget({ id: activeProviderInfo.id, name: activeProviderInfo.name }); setEnqueueRequest({ id: (enqueueIdRef.current += 1), files }); setUploadDialogOpen(true); }, - [activeProviderInfo, canUpload], + [activeProviderInfo, canUploadHere], ); const openUploadDialog = (event: React.MouseEvent) => { - if (!canUpload || !activeProviderInfo) return; + if (!canUploadHere || !activeProviderInfo) return; returnFocusRef.current = event.currentTarget; setUploadTarget({ id: activeProviderInfo.id, name: activeProviderInfo.name }); setEnqueueRequest(null); @@ -267,7 +406,7 @@ export function MediaLibrary({ const handleDragEnter = (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); - if (uploadDialogOpen || !canUpload) return; + if (uploadDialogOpen || !canUploadHere) return; dragDepthRef.current += 1; setIsFileDragActive(true); }; @@ -275,7 +414,7 @@ export function MediaLibrary({ if (hasFiles(event)) event.preventDefault(); }; const handleDragLeave = (event: DragEvent) => { - if (dragDepthRef.current === 0 || uploadDialogOpen || !canUpload) return; + if (dragDepthRef.current === 0 || uploadDialogOpen || !canUploadHere) return; if (event.relatedTarget === null) { resetDrag(); return; @@ -287,7 +426,7 @@ export function MediaLibrary({ if (!hasFiles(event)) return; event.preventDefault(); resetDrag(); - if (uploadDialogOpen || !canUpload) return; + if (uploadDialogOpen || !canUploadHere) return; enqueueFiles([...(event.dataTransfer?.files ?? [])], mediaHeadingRef.current); }; @@ -301,7 +440,7 @@ export function MediaLibrary({ window.removeEventListener("dragleave", handleDragLeave); window.removeEventListener("drop", handleDrop); }; - }, [canUpload, enqueueFiles, uploadDialogOpen]); + }, [canUploadHere, enqueueFiles, uploadDialogOpen]); // Build provider tabs const providerTabs = React.useMemo(() => { @@ -322,6 +461,29 @@ export function MediaLibrary({ const currentItems = activeProvider === "local" ? items : []; const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : []; const currentLoading = activeProvider === "local" ? isLoading : providerLoading; + React.useEffect(() => { + if ( + pendingMediaLibraryScrollTop === null || + currentLoading || + foldersLoading || + currentFolderLoading + ) + return; + let secondFrame: number | undefined; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + const scrollContainer = document.querySelector("main"); + if (scrollContainer && pendingMediaLibraryScrollTop !== null) { + scrollContainer.scrollTop = pendingMediaLibraryScrollTop; + } + pendingMediaLibraryScrollTop = null; + }); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame !== undefined) window.cancelAnimationFrame(secondFrame); + }; + }, [currentFolderLoading, currentLoading, folderId, foldersLoading]); const resultCount = activeProvider === "local" @@ -342,7 +504,154 @@ export function MediaLibrary({ onLocalSearchChange?.(""); } }; - const showToolbar = resultCount > 0 || hasActiveQuery; + const showToolbar = + resultCount > 0 || + hasActiveQuery || + (activeProvider === "local" && + (folders.length > 0 || Boolean(foldersLoading) || Boolean(foldersError))); + const assetPage = pagination?.page ?? 1; + const showFolderResults = + activeProvider === "local" && + assetPage === 1 && + localTypeFilter === "all" && + (!folderId || searchQuery.trim() !== ""); + const visibleFolders = showFolderResults ? folders : []; + const hasFolderSurface = + showFolderResults && + (Boolean(foldersLoading) || + Boolean(foldersError) || + visibleFolders.length > 0 || + hasMoreFolders); + const folderResultsMayFillView = + showFolderResults && + (Boolean(foldersLoading) || + Boolean(foldersError) || + visibleFolders.length > 0 || + (viewMode === "list" && hasFolderSurface)); + const folderActionsAvailable = + Boolean(canManageFolders) && + Boolean(onCreateFolder) && + Boolean(onRenameFolder) && + Boolean(onDeleteFolder); + const dragDropAvailable = + activeProvider === "local" && visibleFolders.length > 0 && Boolean(onMoveMedia); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + const collisionDetection = React.useCallback((args) => { + const dragData = args.active.data.current; + if (!isMediaDragData(dragData)) return []; + const validFolders = args.droppableContainers.filter((container) => { + const target = container.data.current; + return isFolderTargetData(target) && target.folder.id !== dragData.item.folderId; + }); + return pointerWithin({ ...args, droppableContainers: validFolders }); + }, []); + + const clearDragClickSuppression = React.useCallback(() => { + suppressDragClickRef.current = false; + if (suppressDragClickTimerRef.current !== null) { + window.clearTimeout(suppressDragClickTimerRef.current); + suppressDragClickTimerRef.current = null; + } + }, []); + React.useEffect(() => { + const handlePointerUp = () => { + if (!suppressDragClickRef.current) return; + if (suppressDragClickTimerRef.current !== null) + window.clearTimeout(suppressDragClickTimerRef.current); + suppressDragClickTimerRef.current = window.setTimeout(clearDragClickSuppression, 0); + }; + const handlePointerCancel = () => clearDragClickSuppression(); + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") clearDragClickSuppression(); + }; + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerCancel); + window.addEventListener("blur", clearDragClickSuppression); + window.addEventListener("resize", clearDragClickSuppression); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerCancel); + window.removeEventListener("blur", clearDragClickSuppression); + window.removeEventListener("resize", clearDragClickSuppression); + document.removeEventListener("visibilitychange", handleVisibilityChange); + clearDragClickSuppression(); + }; + }, [clearDragClickSuppression]); + const handleRootClickCapture = React.useCallback( + (event: React.MouseEvent) => { + if (!suppressDragClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + clearDragClickSuppression(); + }, + [clearDragClickSuppression], + ); + + const moveMutation = useMutation({ + mutationFn: async ({ item, folder }: { item: LocalMediaItem; folder: MediaFolder }) => { + if (!onMoveMedia) throw new Error("Media move callback unavailable"); + await onMoveMedia(item, folder); + }, + onSuccess: (_result, { folder }) => { + toastManager.add({ + title: t`Moved to ${folder.name}`, + variant: "success", + timeout: 3000, + }); + if (!searchQuery.trim()) mediaHeadingRef.current?.focus({ preventScroll: true }); + }, + onError: (error) => { + const title = t`Couldn’t move file`; + let description: string; + if (error instanceof ApiResponseError && error.code === "NOT_FOUND") { + description = t`The file or folder no longer exists.`; + } else if ( + error instanceof ApiResponseError && + (error.status === 401 || error.status === 403) + ) { + description = t`You don’t have permission to move this file.`; + } else { + description = t`Try again.`; + } + toastManager.add({ title, description, variant: "error" }); + }, + onSettled: () => { + movePendingRef.current = false; + }, + }); + + const handleMediaDragStart = React.useCallback((event: DragStartEvent) => { + const data = event.active.data.current; + if (!isMediaDragData(data)) return; + suppressDragClickRef.current = true; + if (suppressDragClickTimerRef.current !== null) { + window.clearTimeout(suppressDragClickTimerRef.current); + suppressDragClickTimerRef.current = null; + } + setActiveDragItem(data.item); + }, []); + const handleMediaDragCancel = React.useCallback((_event: DragCancelEvent) => { + setActiveDragItem(null); + }, []); + const handleMediaDragEnd = React.useCallback( + (event: DragEndEvent) => { + setActiveDragItem(null); + if (movePendingRef.current || !event.over || !onMoveMedia) return; + const dragData = event.active.data.current; + const targetData = event.over.data.current; + if (!isMediaDragData(dragData) || !isFolderTargetData(targetData)) return; + if (dragData.item.folderId === targetData.folder.id) return; + if (canMoveMedia?.(dragData.item) !== true) return; + movePendingRef.current = true; + moveMutation.mutate({ item: dragData.item, folder: targetData.folder }); + }, + [canMoveMedia, moveMutation, onMoveMedia], + ); const uploadFile = React.useCallback( async (file: File, options: { signal: AbortSignal }) => { if (!uploadTarget) throw new Error("Upload target unavailable"); @@ -367,10 +676,77 @@ export function MediaLibrary({ const handleUploadQueueIdle = React.useCallback(() => { if (uploadTarget?.id !== "local") void refetchProviderMedia(); }, [refetchProviderMedia, uploadTarget?.id]); + const openCreateFolder = (event: React.MouseEvent) => { + folderDialogReturnFocusRef.current = event.currentTarget; + setEditingFolder(null); + setFolderDialogOpen(true); + }; + const openEditFolder = (folder: MediaFolder, trigger: HTMLElement) => { + folderDialogReturnFocusRef.current = trigger; + setEditingFolder(folder); + setFolderDialogOpen(true); + }; + const closeFolderDialog = () => { + setFolderDialogOpen(false); + const returnTarget = folderDialogReturnFocusRef.current; + folderDialogReturnFocusRef.current = null; + window.requestAnimationFrame(() => { + (returnTarget?.isConnected ? returnTarget : mediaHeadingRef.current)?.focus({ + preventScroll: true, + }); + }); + }; + const focusMediaHeading = () => mediaHeadingRef.current?.focus({ preventScroll: true }); + const rememberScrollPosition = () => { + pendingMediaLibraryScrollTop = mediaHeadingRef.current?.closest("main")?.scrollTop ?? null; + }; + const backToMain = () => { + rememberScrollPosition(); + focusMediaHeading(); + onBackToMain?.(); + }; + const openFolder = (folder: MediaFolder) => { + setSearchQuery(""); + onLocalSearchChange?.(""); + cancelPendingDetailOpen(); + setIsDetailOpen(false); + setDetailItem(null); + rememberScrollPosition(); + focusMediaHeading(); + onOpenFolder?.(folder); + }; + const uploadActionLabel = + activeProvider === "local" + ? t`Upload Files` + : t`Upload to ${activeProviderInfo?.name || t`Library`}`; - return ( -
- {isFileDragActive && ( + return React.createElement( + DndContext, + { + sensors, + collisionDetection, + accessibility: { + announcements: { + onDragStart: () => "", + onDragOver: () => "", + onDragEnd: () => "", + onDragCancel: () => "", + }, + restoreFocus: false, + screenReaderInstructions: { draggable: "" }, + }, + onDragStart: handleMediaDragStart, + onDragEnd: handleMediaDragEnd, + onDragCancel: handleMediaDragCancel, + }, +
+ {onMoveMedia && {null}} + {isFileDragActive && canUploadHere && ( )} - {/* Header: page title (start) + primary upload action (end) */} -
-

- {t`Media Library`} -

-
- {canUpload && ( - + )} + {canUploadHere && ( + )}
@@ -406,6 +845,7 @@ export function MediaLibrary({ if (!v) return; cancelPendingDetailOpen(); setActiveProvider(v); + onActiveProviderChange?.(v); setIsDetailOpen(false); setDetailItem(null); setSearchQuery(""); @@ -430,8 +870,8 @@ export function MediaLibrary({ {/* Toolbar: search + type filter (start) · view toggle (end). Local library search/filter is handled server-side. */} {showToolbar && ( -
-
+
+
{(canSearch || activeProvider === "local") && (
)} + {activeProvider === "local" && ( + + {!hasFolderSurface || foldersError + ? "" + : foldersLoading || isLoadingMoreFolders + ? t`Loading folders` + : plural(visibleFolders.length, { + one: "# folder loaded", + other: "# folders loaded", + })} + + )} + + {hasFolderSurface && viewMode === "grid" && ( +
+
+

+ {t`Folders`} +

+ {foldersError && onRetryFolders && ( + + )} +
+ {foldersError && ( +
+ {t`Folders could not be loaded.`} +
+ )} + {foldersLoading && visibleFolders.length === 0 ? ( +
+ +
+ ) : ( + + {visibleFolders.map((folder) => ( + openFolder(folder) : undefined} + onEdit={(trigger) => openEditFolder(folder, trigger)} + /> + ))} + + )} + {hasMoreFolders && onLoadMoreFolders && ( +
+ +
+ )} + {visibleFolders.length > 0 && currentItems.length > 0 && ( +
+ )} +
+ )} + {/* Content */} {currentLoading && currentItems.length === 0 && currentProviderItems.length === 0 ? (
- ) : activeProvider === "local" && currentItems.length === 0 ? ( + ) : activeProvider === "local" && currentItems.length === 0 && !folderResultsMayFillView ? ( hasActiveQuery ? ( } /> + ) : folderId ? ( + + {t`Back to Main library`} + + } + /> ) : ( openDetail(item)} /> )) @@ -611,7 +1140,12 @@ export function MediaLibrary({ inert={currentLoading || undefined} className="rounded-md border bg-kumo-base overflow-x-auto" > - +
@@ -622,12 +1156,63 @@ export function MediaLibrary({ + {showFolderResults && foldersLoading && visibleFolders.length === 0 && ( + + + + )} + {showFolderResults && foldersError && visibleFolders.length === 0 && ( + + )} + {activeProvider === "local" && + visibleFolders.map((folder) => ( + openFolder(folder) : undefined} + onEdit={(trigger) => openEditFolder(folder, trigger)} + /> + ))} + {showFolderResults && foldersError && visibleFolders.length > 0 && ( + + )} + {showFolderResults && hasMoreFolders && onLoadMoreFolders && ( + + + + )} {activeProvider === "local" ? currentItems.map((item) => ( openDetail(item)} /> )) @@ -733,13 +1318,232 @@ export function MediaLibrary({ item={detailItem} providerName={detailItem.provider ? activeProviderInfo?.name : undefined} canDelete={detailItem.provider ? activeProviderInfo?.capabilities.delete : undefined} + canMoveLocation={isLocalMediaItem(detailItem) ? canMoveMedia?.(detailItem) : undefined} restoreFocusTargetRef={mediaHeadingRef} onClose={closeDetail} onClosed={handleDetailClosed} onUpdated={onItemUpdated} + onItemRefreshed={handleDetailItemRefreshed} onDeleted={detailItem.provider ? undefined : onItemUpdated} /> )} + + {folderActionsAvailable && onCreateFolder && onRenameFolder && onDeleteFolder && ( + + )} + , + + {activeDragItem ? : null} + , + ); +} + +function MediaFolderCard({ + folder, + canEdit, + canDrop, + activeDragItem, + onOpen, + onEdit, +}: { + folder: MediaFolder; + canEdit: boolean; + canDrop: boolean; + activeDragItem: LocalMediaItem | null; + onOpen?: () => void; + onEdit: (trigger: HTMLElement) => void; +}) { + const { t } = useLingui(); + const { setNodeRef, isOver } = useDroppable({ + id: folderDropId(folder.id), + data: { kind: "media-folder-target", folder } satisfies MediaFolderTargetData, + disabled: !canDrop, + }); + const isValidTarget = isOver && activeDragItem?.folderId !== folder.id; + return ( + + handleNavigationClick(event, onOpen)} + > +
+
+ + {folder.name} + +
+ {canEdit && ( + + )} +
+ ); +} + +function MediaFolderListItem({ + folder, + canEdit, + canDrop, + activeDragItem, + onOpen, + onEdit, +}: { + folder: MediaFolder; + canEdit: boolean; + canDrop: boolean; + activeDragItem: LocalMediaItem | null; + onOpen?: () => void; + onEdit: (trigger: HTMLElement) => void; +}) { + const { t } = useLingui(); + const { setNodeRef, isOver } = useDroppable({ + id: folderDropId(folder.id), + data: { kind: "media-folder-target", folder } satisfies MediaFolderTargetData, + disabled: !canDrop, + }); + const isValidTarget = isOver && activeDragItem?.folderId !== folder.id; + return ( +
+ + + + + + + ); +} + +function MediaFolderErrorRow({ onRetry }: { onRetry?: () => void }) { + const { t } = useLingui(); + return ( + + + + ); +} + +function handleNavigationClick( + event: React.MouseEvent, + navigate: (() => void) | undefined, +) { + if ( + !navigate || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + navigate(); +} + +function isLocalMediaItem(item: MediaItem): item is LocalMediaItem { + return ( + !item.provider && + "folderId" in item && + "authorId" in item && + typeof item.storageKey === "string" + ); +} + +function MediaDragOverlay({ item }: { item: LocalMediaItem }) { + return ( + ); } @@ -791,19 +1595,35 @@ function MediaEmptyState({ hero, title, description, action }: MediaEmptyStatePr interface MediaGridItemProps { item: MediaItem; selected?: boolean; + draggable?: boolean; + isMoving?: boolean; onClick?: () => void; } -function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) { +function MediaGridItem({ item, selected, draggable, isMoving, onClick }: MediaGridItemProps) { const isImage = item.mimeType.startsWith("image/"); + const localItem = isLocalMediaItem(item) ? item : null; + const { setNodeRef, listeners, isDragging } = useDraggable({ + id: mediaDragId(item.id), + data: localItem + ? ({ kind: "local-media", item: localItem } satisfies MediaDragData) + : undefined, + disabled: !draggable || !localItem, + }); return ( @@ -902,6 +1739,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) { {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> diff --git a/packages/admin/src/components/RouterLinkButton.tsx b/packages/admin/src/components/RouterLinkButton.tsx index 3c1b8bcc09..3f75abb8c1 100644 --- a/packages/admin/src/components/RouterLinkButton.tsx +++ b/packages/admin/src/components/RouterLinkButton.tsx @@ -36,6 +36,7 @@ export type RouterLinkButtonProps = Omit & ButtonStyleProps & { className?: string; children?: React.ReactNode; + onClick?: React.MouseEventHandler; }; export function RouterLinkButton({ diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index c8acad2727..b928e297c8 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -22,6 +22,18 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export class ApiResponseError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public details?: Record, + ) { + super(message); + this.name = "ApiResponseError"; + } +} + /** * Extract per-field validation issue messages from a `VALIDATION_ERROR` * response's `error.details.issues` array (see `packages/core/src/api/parse.ts`). @@ -56,12 +68,21 @@ function formatValidationIssues(error: Record): string | undefi export async function throwResponseError(res: Response, fallback: string): Promise { const body: unknown = await res.json().catch(() => ({})); let message: string | undefined; + let code = "UNKNOWN_ERROR"; + let details: Record | undefined; if (isRecord(body) && isRecord(body.error)) { const { error } = body; message = formatValidationIssues(error); if (!message && typeof error.message === "string") message = error.message; + if (typeof error.code === "string") code = error.code; + if (isRecord(error.details)) details = error.details; } - throw new Error(message || `${fallback}: ${res.statusText}`); + throw new ApiResponseError( + res.status, + code, + message || `${fallback}: ${res.statusText}`, + details, + ); } /** diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index f8a4482692..298d67f819 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -7,6 +7,7 @@ // Base client and shared types export { API_BASE, + ApiResponseError, apiFetch, parseApiResponse, throwResponseError, @@ -58,6 +59,9 @@ export { // Media export { type MediaItem, + type LocalMediaItem, + type MediaFolder, + type MediaFolderListResult, type MediaUploadOptions, type MediaProviderCapabilities, type MediaProviderInfo, @@ -66,6 +70,11 @@ export { MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, fetchMediaItem, + fetchMediaFolders, + fetchMediaFolder, + createMediaFolder, + renameMediaFolder, + deleteMediaFolder, uploadMedia, deleteMedia, updateMedia, diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 77a1d94e56..05c4993781 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -51,10 +51,24 @@ export interface MediaItem { meta?: Record; } -export interface MediaListResult extends FindManyResult { +export interface LocalMediaItem extends MediaItem { + provider?: undefined; + storageKey: string; + authorId: string | null; + folderId: string | null; +} + +export interface MediaFolder { + id: string; + name: string; +} + +export interface MediaListResult extends FindManyResult { totalCount?: number; } +export interface MediaFolderListResult extends FindManyResult {} + /** * Fetch media list */ @@ -63,6 +77,7 @@ export async function fetchMediaList(options?: { page?: number; limit?: number; mimeType?: string | string[]; + folderId?: string | null; /** Case-insensitive filename substring search (also matches extensions). */ search?: string; }): Promise { @@ -74,6 +89,11 @@ export async function fetchMediaList(options?: { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } + if (options?.folderId === null) { + params.set("folderId", "unfiled"); + } else if (options?.folderId !== undefined) { + params.set("folderId", options.folderId); + } if (options?.search) { // Trim and clamp to the server's accepted range so a long or // whitespace-only term can't trigger an avoidable 400. @@ -92,15 +112,78 @@ export async function fetchMediaList(options?: { * Used to resolve an id-only reference (e.g. a byline's `avatarMediaId`) * back into a full media item for display. */ -export async function fetchMediaItem(id: string, options?: MediaUploadOptions): Promise { - const response = await apiFetch(`${API_BASE}/media/${id}`, { signal: options?.signal }); - const data = await parseApiResponse<{ item: MediaItem }>( +export async function fetchMediaItem( + id: string, + options?: MediaUploadOptions, +): Promise { + const response = await apiFetch(`${API_BASE}/media/${encodeURIComponent(id)}`, { + signal: options?.signal, + }); + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to fetch media item`), ); return data.item; } +export async function fetchMediaFolders( + options: { limit?: number; cursor?: string; search?: string } = {}, +): Promise { + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set("limit", String(options.limit)); + if (options.cursor !== undefined) params.set("cursor", options.cursor); + const search = normalizeMediaSearch(options.search); + if (search) params.set("q", search); + const query = params.toString(); + const response = await apiFetch(`${API_BASE}/media/folders${query ? `?${query}` : ""}`); + return parseApiResponse( + response, + i18n._(msg`Failed to fetch media folders`), + ); +} + +export async function fetchMediaFolder(id: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to fetch media folder`), + ); + return data.item; +} + +export async function createMediaFolder(name: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to create media folder`), + ); + return data.item; +} + +export async function renameMediaFolder(id: string, name: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to rename media folder`), + ); + return data.item; +} + +export async function deleteMediaFolder(id: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete media folder`)); +} + /** * Upload URL response from the API */ @@ -346,14 +429,20 @@ export async function deleteMedia(id: string): Promise { */ export async function updateMedia( id: string, - input: { alt?: string; caption?: string; width?: number; height?: number }, -): Promise { - const response = await apiFetch(`${API_BASE}/media/${id}`, { + input: { + alt?: string; + caption?: string; + width?: number; + height?: number; + folderId?: string | null; + }, +): Promise { + const response = await apiFetch(`${API_BASE}/media/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); - const data = await parseApiResponse<{ item: MediaItem }>( + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to update media`), ); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 1ad1103811..95e5350d2b 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -85,6 +85,7 @@ import { deleteContent, fetchTranslations, fetchMediaList, + updateMedia, uploadMedia, fetchCollections, fetchCollection, @@ -113,6 +114,12 @@ import { unpublishContent, discardDraft, fetchRevision, + fetchMediaFolder, + fetchMediaFolders, + createMediaFolder, + renameMediaFolder, + deleteMediaFolder, + ApiResponseError, useCurrentUser, type CreateCollectionInput, type UpdateCollectionInput, @@ -821,7 +828,8 @@ const contentEditRoute = createRoute({ }), }); -// Editor role level from @emdash-cms/auth +// Role levels from @emdash-cms/auth +const ROLE_AUTHOR = 30; const ROLE_EDITOR = 40; function ContentEditPage() { @@ -1371,26 +1379,91 @@ const mediaRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/media", component: MediaPage, + validateSearch: (search: Record) => ({ + folder: + typeof search.folder === "string" && search.folder.length > 0 && search.folder.length <= 64 + ? search.folder + : undefined, + }), }); function MediaPage() { + const { t } = useLingui(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { folder } = useSearch({ from: "/_admin/media" }); + const toastManager = Toast.useToastManager(); + const { data: currentUser } = useCurrentUser(); const [search, setSearch] = React.useState(""); const [mimeFilter, setMimeFilter] = React.useState(undefined); const [page, setPage] = React.useState(1); const [perPage, setPerPage] = React.useState(35); const [retainedTotalCount, setRetainedTotalCount] = React.useState(0); + const [activeProvider, setActiveProvider] = React.useState("local"); const mimeKey = Array.isArray(mimeFilter) ? mimeFilter.join(",") : (mimeFilter ?? ""); + const currentFolderQuery = useQuery({ + queryKey: ["media-folder", folder], + queryFn: () => fetchMediaFolder(folder!), + enabled: folder !== undefined, + retry: (failureCount, queryError) => + !(queryError instanceof ApiResponseError && queryError.code === "NOT_FOUND") && + failureCount < 2, + }); + const missingFolder = + currentFolderQuery.error instanceof ApiResponseError && + currentFolderQuery.error.code === "NOT_FOUND"; + const recoveredFolderRef = React.useRef(null); + React.useEffect(() => { + if (!folder || !missingFolder || recoveredFolderRef.current === folder) return; + recoveredFolderRef.current = folder; + void navigate({ to: "/media", search: { folder: undefined }, replace: true }); + toastManager.add({ + title: t`Folder no longer exists`, + type: "warning", + timeout: 4000, + }); + }, [folder, missingFolder, navigate, t, toastManager]); + React.useEffect(() => { + if (folder !== recoveredFolderRef.current) recoveredFolderRef.current = null; + }, [folder]); + const previousFolderRef = React.useRef(folder); + const folderChanged = previousFolderRef.current !== folder; + const requestedPage = folderChanged ? 1 : page; + const folderListEnabled = + activeProvider === "local" && + requestedPage === 1 && + mimeFilter === undefined && + (folder === undefined || search !== ""); + const folderListQuery = useInfiniteQuery({ + queryKey: ["media-folders", "page", { search }], + queryFn: ({ pageParam }) => + fetchMediaFolders({ + limit: 100, + cursor: pageParam, + search: search || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: folderListEnabled, + }); + const folders = React.useMemo( + () => folderListQuery.data?.pages.flatMap((folderPage) => folderPage.items) ?? [], + [folderListQuery.data?.pages], + ); const { data, isLoading, isFetching, error } = useQuery({ - queryKey: ["media", { search, mime: mimeKey, page, perPage }], + queryKey: [ + "media", + { search, mime: mimeKey, folder: folder ?? "main", page: requestedPage, perPage }, + ], queryFn: () => fetchMediaList({ - page, + page: requestedPage, limit: perPage, search: search || undefined, mimeType: mimeFilter, + folderId: search ? undefined : (folder ?? null), }), placeholderData: keepPreviousData, }); @@ -1398,10 +1471,16 @@ function MediaPage() { React.useEffect(() => { if (data?.totalCount !== undefined) setRetainedTotalCount(data.totalCount); }, [data?.totalCount]); + React.useEffect(() => { + if (previousFolderRef.current === folder) return; + previousFolderRef.current = folder; + setPage(1); + setRetainedTotalCount(0); + }, [folder]); const totalCount = data?.totalCount ?? retainedTotalCount; const lastPage = Math.max(1, Math.ceil((data?.totalCount ?? 0) / perPage)); - const isRecoveringPage = data?.totalCount !== undefined && page > lastPage; + const isRecoveringPage = data?.totalCount !== undefined && requestedPage > lastPage; React.useEffect(() => { if (isRecoveringPage) setPage(lastPage); }, [isRecoveringPage, lastPage]); @@ -1450,6 +1529,103 @@ function MediaPage() { void queryClient.invalidateQueries({ queryKey: ["media"] }); }, }); + const resetMediaPage = React.useCallback(() => { + setPage(1); + setRetainedTotalCount(0); + }, []); + const handleOpenFolder = React.useCallback( + (nextFolder: { id: string }) => { + resetMediaPage(); + void navigate({ to: "/media", search: { folder: nextFolder.id }, resetScroll: false }); + }, + [navigate, resetMediaPage], + ); + const handleBackToMain = React.useCallback(() => { + resetMediaPage(); + void navigate({ to: "/media", search: { folder: undefined }, resetScroll: false }); + }, [navigate, resetMediaPage]); + const handleCreateFolder = React.useCallback( + async (name: string) => { + const created = await createMediaFolder(name); + resetMediaPage(); + await queryClient.invalidateQueries({ queryKey: ["media-folders"] }); + return created; + }, + [queryClient, resetMediaPage], + ); + const handleRenameFolder = React.useCallback( + async (targetFolder: { id: string }, name: string) => { + const renamed = await renameMediaFolder(targetFolder.id, name); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["media-folders"] }), + queryClient.invalidateQueries({ queryKey: ["media-folder", targetFolder.id] }), + ]); + return renamed; + }, + [queryClient], + ); + const handleDeleteFolder = React.useCallback( + async (targetFolder: { id: string }) => { + await deleteMediaFolder(targetFolder.id); + const deletingCurrentFolder = folder === targetFolder.id; + if (deletingCurrentFolder) { + resetMediaPage(); + await navigate({ + to: "/media", + search: { folder: undefined }, + replace: true, + resetScroll: false, + }); + } + queryClient.removeQueries({ queryKey: ["media-folder", targetFolder.id], exact: true }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["media-folders"] }), + queryClient.invalidateQueries({ queryKey: ["media"] }), + ]); + if (!deletingCurrentFolder) resetMediaPage(); + }, + [folder, navigate, queryClient, resetMediaPage], + ); + const handleMoveMedia = React.useCallback( + async (item: { id: string }, destination: { id: string }) => { + try { + await updateMedia(item.id, { folderId: destination.id }); + await queryClient.invalidateQueries({ queryKey: ["media"] }); + } catch (moveError) { + const recovery: Promise[] = [ + queryClient.invalidateQueries({ queryKey: ["media"] }), + ]; + if (moveError instanceof ApiResponseError && moveError.code === "NOT_FOUND") { + recovery.push( + queryClient.invalidateQueries({ queryKey: ["media-folders"] }), + queryClient.invalidateQueries({ queryKey: ["media-folder"] }), + ); + } + if ( + moveError instanceof ApiResponseError && + (moveError.status === 401 || moveError.status === 403) + ) { + recovery.push(queryClient.resetQueries({ queryKey: ["currentUser"], exact: true })); + } + await Promise.allSettled(recovery); + throw moveError; + } + }, + [queryClient], + ); + const canMoveMedia = React.useCallback( + (item: { authorId: string | null }) => + Boolean( + currentUser && + (currentUser.role >= ROLE_EDITOR || + (currentUser.role >= ROLE_AUTHOR && item.authorId === currentUser.id)), + ), + [currentUser], + ); + + if (currentFolderQuery.error && !missingFolder) { + return ; + } if (error) { return ; @@ -1460,7 +1636,7 @@ function MediaPage() { items={isRecoveringPage ? [] : (data?.items ?? [])} isLoading={paginationPending} pagination={{ - page: isRecoveringPage ? lastPage : page, + page: isRecoveringPage ? lastPage : requestedPage, perPage, totalCount, isPending: paginationPending, @@ -1472,6 +1648,25 @@ function MediaPage() { }} onLocalSearchChange={handleSearchChange} onLocalMimeFilterChange={handleMimeFilterChange} + folders={folders} + foldersLoading={folderListQuery.isLoading} + foldersError={folderListQuery.error} + hasMoreFolders={folderListQuery.hasNextPage} + isLoadingMoreFolders={folderListQuery.isFetchingNextPage} + onLoadMoreFolders={() => void folderListQuery.fetchNextPage()} + onActiveProviderChange={setActiveProvider} + folderId={folder} + currentFolder={currentFolderQuery.data ?? null} + currentFolderLoading={currentFolderQuery.isLoading} + canManageFolders={(currentUser?.role ?? 0) >= ROLE_EDITOR} + onOpenFolder={handleOpenFolder} + onBackToMain={handleBackToMain} + onRetryFolders={() => void folderListQuery.refetch()} + onCreateFolder={handleCreateFolder} + onRenameFolder={handleRenameFolder} + onDeleteFolder={handleDeleteFolder} + canMoveMedia={canMoveMedia} + onMoveMedia={handleMoveMedia} /> ); } diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css index b8fd689a28..a4dc227e3c 100644 --- a/packages/admin/src/styles.css +++ b/packages/admin/src/styles.css @@ -79,6 +79,7 @@ * Override Tailwind's --font-sans so all font-sans utilities use it. */ @theme { + --text-color-emdash-media-folder: light-dark(var(--color-blue-500), var(--color-blue-400)); --font-sans: var( --font-emdash, ui-sans-serif, diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index 03834f78a9..de7f4ae53c 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaDetailPanel } from "../../src/components/MediaDetailPanel"; -import type { MediaItem } from "../../src/lib/api"; +import { ApiResponseError, type LocalMediaItem, type MediaItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; vi.mock("../../src/lib/api", async () => { @@ -13,11 +13,21 @@ vi.mock("../../src/lib/api", async () => { updateMedia: vi.fn().mockResolvedValue({}), deleteMedia: vi.fn().mockResolvedValue({}), deleteFromProvider: vi.fn().mockResolvedValue({}), + fetchMediaFolders: vi.fn().mockResolvedValue({ items: [{ id: "folder-2", name: "Press" }] }), + fetchMediaFolder: vi.fn().mockResolvedValue({ id: "folder-1", name: "Product photos" }), + fetchMediaItem: vi.fn().mockResolvedValue({}), }; }); // Import the mocked functions for assertions -import { updateMedia, deleteMedia, deleteFromProvider } from "../../src/lib/api"; +import { + updateMedia, + deleteMedia, + deleteFromProvider, + fetchMediaFolders, + fetchMediaFolder, + fetchMediaItem, +} from "../../src/lib/api"; function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ @@ -92,6 +102,16 @@ function makeLocalVideoItem(overrides: Partial = {}): MediaItem { }; } +function makeLocalItem(overrides: Partial = {}): LocalMediaItem { + return { + ...makeImageItem(), + storageKey: "media-1.jpg", + authorId: "user-1", + folderId: "folder-1", + ...overrides, + }; +} + function renderPanel(props: Partial> = {}) { const defaultProps: React.ComponentProps = { open: true, @@ -135,40 +155,33 @@ describe("MediaDetailPanel", () => { await expect.element(screen.getByText("1920 × 1080")).toBeInTheDocument(); }); - it("renders the responsive two-column viewport-bounded dialog layout", async () => { + it("groups the preview, metadata, and actions in an accessible dialog", async () => { const screen = await renderPanel(); const dialog = screen.getByRole("dialog", { name: "Media Details" }).element(); - const header = screen.getByTestId("media-detail-dialog-header").element(); - const body = screen.getByTestId("media-detail-dialog-body").element(); - const previewColumn = screen.getByTestId("media-detail-dialog-preview-column").element(); - const detailsColumn = screen.getByTestId("media-detail-dialog-details-column").element(); - const fileFacts = screen.getByTestId("media-detail-dialog-file-facts").element(); - const footer = screen.getByTestId("media-detail-dialog-footer").element(); - - expect(body.className).toContain("grid-cols-1"); - expect(body.className).toContain("md:grid-cols-2"); - expect(dialog.style.height).toBe(""); - expect(dialog.style.maxHeight).toBe("min(88dvh, 48rem)"); - expect(dialog.className).toContain("data-starting-style:scale-90"); - expect(dialog.className).toContain("data-starting-style:opacity-0"); - expect(dialog.style.transitionProperty).toBe("scale, opacity"); - expect(header.style.padding).toBe("1.25rem 2rem"); - expect(previewColumn.contains(fileFacts)).toBe(true); - expect(detailsColumn.contains(fileFacts)).toBe(false); - expect(previewColumn.className).toContain("md:p-8"); - expect(detailsColumn.className).toContain("md:p-8"); - // Columns only constrain/scroll at md+; on mobile the body scrolls as one - // column so collapsed columns can't compress and overlap their content. - expect(body.className).toContain("overflow-y-auto"); - expect(body.className).toContain("md:overflow-hidden"); - expect(previewColumn.className).toContain("md:min-h-0"); - expect(previewColumn.className).toContain("md:overflow-y-auto"); - expect(previewColumn.className).not.toContain(" min-h-0"); - expect(detailsColumn.className).toContain("md:min-h-0"); - expect(detailsColumn.className).toContain("md:overflow-y-auto"); - expect(detailsColumn.className).not.toContain(" min-h-0"); - expect(fileFacts.className).toContain("space-y-3"); - expect(footer.style.padding).toBe("1.25rem 2rem"); + const preview = screen.getByAltText("A nice photo"); + const filename = screen.getByLabelText("Filename"); + const altText = screen.getByLabelText("Alt Text"); + const caption = screen.getByLabelText("Caption"); + const close = screen.getByRole("button", { name: "Close" }); + const deleteButton = screen.getByRole("button", { name: "Delete" }); + const cancel = screen.getByRole("button", { name: "Cancel" }); + const save = screen.getByRole("button", { name: "Save" }); + + for (const locator of [ + preview, + filename, + altText, + caption, + close, + deleteButton, + cancel, + save, + ]) { + await expect.element(locator).toBeVisible(); + expect(dialog).toContainElement(locator.element()); + } + await expect.element(screen.getByText("200 KB")).toBeVisible(); + await expect.element(screen.getByText("1920 × 1080")).toBeVisible(); }); it("shows image preview for image mimeTypes", async () => { @@ -191,7 +204,6 @@ describe("MediaDetailPanel", () => { const screen = await renderPanel({ item }); const altInput = screen.getByLabelText("Alt Text"); await expect.element(altInput).toBeInTheDocument(); - expect(altInput.element().className).toContain("w-full"); await expect .element(screen.getByRole("button", { name: "Why is this important?" })) .toBeInTheDocument(); @@ -224,8 +236,6 @@ describe("MediaDetailPanel", () => { const screen = await renderPanel({ item }); const filenameInput = screen.getByLabelText("Filename"); await expect.element(filenameInput).toBeDisabled(); - expect(filenameInput.element().className).toContain("bg-kumo-tint"); - expect(filenameInput.element().className).toContain("w-full"); await expect .element(screen.getByRole("button", { name: "Why can't this be changed?" })) .toBeInTheDocument(); @@ -328,6 +338,302 @@ describe("MediaDetailPanel", () => { }); }); + it("loads bounded Location options only after the control opens", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + expect(fetchMediaFolders).not.toHaveBeenCalled(); + const location = screen.getByRole("combobox", { name: "Location" }); + await expect.element(location).toHaveTextContent("Product photos"); + expect(location.element().querySelector('[dir="auto"]')).toHaveTextContent("Product photos"); + + location.element().click(); + + await vi.waitFor(() => { + expect(fetchMediaFolders).toHaveBeenCalledWith({ + limit: 100, + cursor: undefined, + search: undefined, + }); + }); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Press" }).element().querySelector('[dir="auto"]'), + ).toHaveTextContent("Press"); + await expect.element(screen.getByText("1 folder loaded")).toBeInTheDocument(); + }); + + it("saves image metadata and Location in one update", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await screen.getByLabelText("Alt Text").fill("Updated alt"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { + alt: "Updated alt", + caption: "Photo caption", + folderId: "folder-2", + }); + }); + }); + + it("does not overwrite Location during a metadata-only save", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + await screen.getByLabelText("Alt Text").fill("Metadata only"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { + alt: "Metadata only", + caption: "Photo caption", + }); + }); + }); + + it("searches Location independently and resets the search after selection", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + const locationTrigger = screen + .getByTestId("media-detail-dialog-details-column") + .getByRole("combobox", { name: "Location" }); + + locationTrigger.element().click(); + await screen.getByPlaceholder("Search folders").fill("press"); + await vi.waitFor(() => { + expect(fetchMediaFolders).toHaveBeenLastCalledWith({ + limit: 100, + cursor: undefined, + search: "press", + }); + }); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).not.toBeInTheDocument(); + locationTrigger.element().click(); + + await expect.element(screen.getByPlaceholder("Search folders")).toHaveValue(""); + }); + + it("ignores duplicate Location saves while the first update is pending", async () => { + let resolveUpdate!: (item: LocalMediaItem) => void; + vi.mocked(updateMedia).mockImplementationOnce( + () => new Promise((resolve) => (resolveUpdate = resolve)), + ); + const item = makeLocalItem(); + const screen = await renderPanel({ item, canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + const save = screen.getByRole("button", { name: "Save" }).element(); + save.click(); + save.click(); + + await vi.waitFor(() => expect(updateMedia).toHaveBeenCalledTimes(1)); + resolveUpdate({ ...item, folderId: "folder-2" }); + }); + + it.each([ + ["video", "video/mp4"], + ["audio", "audio/mpeg"], + ["document", "application/pdf"], + ])("moves a local %s without image metadata", async (_kind, mimeType) => { + const screen = await renderPanel({ + item: makeLocalItem({ mimeType, alt: undefined, caption: undefined }), + canMoveLocation: true, + }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { folderId: null }); + }); + }); + + it("loads one additional bounded Location page on request", async () => { + vi.mocked(fetchMediaFolders).mockImplementation(async ({ cursor }) => + cursor === "next-folder" + ? { items: [{ id: "folder-3", name: "Archive" }] } + : { items: [{ id: "folder-2", name: "Press" }], nextCursor: "next-folder" }, + ); + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect + .element(screen.getByRole("button", { name: "Load more folders" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Load more folders" }).element().click(); + + await expect.element(screen.getByRole("option", { name: "Archive" })).toBeInTheDocument(); + expect(fetchMediaFolders).toHaveBeenLastCalledWith({ + limit: 100, + cursor: "next-folder", + search: undefined, + }); + }); + + it("shows a read-only Location when the user cannot move the item", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: false }); + + await expect.element(screen.getByText("Location")).toBeInTheDocument(); + const currentLocation = screen.getByText("Product photos"); + await expect.element(currentLocation).toBeInTheDocument(); + expect(currentLocation.element()).toHaveAttribute("dir", "auto"); + expect(screen.getByRole("combobox", { name: "Location" }).query()).toBeNull(); + expect(fetchMediaFolders).not.toHaveBeenCalled(); + }); + + it("refreshes the open item when its saved folder no longer exists", async () => { + const refreshed = makeLocalItem({ folderId: null }); + let resolveRefresh!: (item: LocalMediaItem) => void; + vi.mocked(fetchMediaFolder).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)), + ); + const onItemRefreshed = vi.fn(); + + const screen = await renderPanel({ + item: makeLocalItem(), + canMoveLocation: true, + onItemRefreshed, + }); + + await vi.waitFor(() => expect(fetchMediaItem).toHaveBeenCalledWith("media-1")); + await expect + .element(screen.getByRole("combobox", { name: "Location" })) + .toHaveTextContent("Loading..."); + resolveRefresh(refreshed); + await vi.waitFor(() => { + expect(onItemRefreshed).toHaveBeenCalledWith(refreshed); + }); + }); + + it("refreshes the open item when a selected folder disappears during save", async () => { + const refreshed = makeLocalItem({ folderId: null }); + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockResolvedValueOnce(refreshed); + const onItemRefreshed = vi.fn(); + const screen = await renderPanel({ + item: makeLocalItem(), + canMoveLocation: true, + onItemRefreshed, + }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(fetchMediaItem).toHaveBeenCalledWith("media-1"); + expect(onItemRefreshed).toHaveBeenCalledWith(refreshed); + }); + await expect + .element( + screen.getByText( + "The selected folder no longer exists. Choose another location and save again.", + ), + ) + .toBeInTheDocument(); + }); + + it("blocks stale save retries while missing-folder recovery is pending", async () => { + let resolveRefresh!: (item: LocalMediaItem) => void; + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)), + ); + const item = makeLocalItem(); + const screen = await renderPanel({ item, canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + const save = screen.getByRole("button", { name: "Save" }).element(); + save.click(); + + await vi.waitFor(() => expect(fetchMediaItem).toHaveBeenCalledWith("media-1")); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + const shortcut = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true }); + window.dispatchEvent(shortcut); + expect(shortcut.defaultPrevented).toBe(false); + save.click(); + expect(updateMedia).toHaveBeenCalledTimes(1); + resolveRefresh({ ...item, folderId: null }); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("reports when the media itself was deleted during a save", async () => { + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + vi.mocked(fetchMediaItem).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await expect.element(screen.getByText("This media item no longer exists.")).toBeInTheDocument(); + expect( + screen + .getByText("The selected folder no longer exists. Choose another location and save again.") + .query(), + ).toBeNull(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("does not blame the folder when missing-item recovery cannot confirm the state", async () => { + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + vi.mocked(fetchMediaItem).mockRejectedValueOnce( + new ApiResponseError(503, "MEDIA_FETCH_ERROR", "Failed to fetch media item"), + ); + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + const save = screen.getByRole("button", { name: "Save" }); + await expect.element(save).toBeEnabled(); + save.element().click(); + + await expect + .element( + screen.getByText( + "Couldn’t confirm whether the media item or selected folder still exists. Try again.", + ), + ) + .toBeInTheDocument(); + expect( + screen + .getByText("The selected folder no longer exists. Choose another location and save again.") + .query(), + ).toBeNull(); + }); + it("does not consume the keyboard save shortcut when nothing can be saved", async () => { await renderPanel({ item: makeImageItem({ provider: "cloudflare-images" }), @@ -534,6 +840,9 @@ describe("MediaDetailPanel file URL", () => { .element(screen.getByLabelText("Alt Text"), { timeout: 100 }) .not.toBeInTheDocument(); await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument(); + await expect.element(screen.getByText("Location"), { timeout: 100 }).not.toBeInTheDocument(); + expect(fetchMediaFolder).not.toHaveBeenCalled(); + expect(fetchMediaFolders).not.toHaveBeenCalled(); }); describe("video preview", () => { diff --git a/packages/admin/tests/components/MediaFolderDialog.test.tsx b/packages/admin/tests/components/MediaFolderDialog.test.tsx new file mode 100644 index 0000000000..75d1edd235 --- /dev/null +++ b/packages/admin/tests/components/MediaFolderDialog.test.tsx @@ -0,0 +1,155 @@ +import { Toasty } from "@cloudflare/kumo"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { MediaFolderDialog } from "../../src/components/MediaFolderDialog"; +import { ApiResponseError } from "../../src/lib/api"; +import { render } from "../utils/render.tsx"; + +async function renderDialog(props: Partial> = {}) { + const defaults: React.ComponentProps = { + open: true, + onClose: vi.fn(), + onCreate: vi.fn().mockResolvedValue({ id: "folder-1", name: "Created" }), + onRename: vi.fn().mockResolvedValue({ id: "folder-1", name: "Renamed" }), + onDelete: vi.fn().mockResolvedValue(undefined), + ...props, + }; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Harness() { + const [open, setOpen] = React.useState(true); + return ( + { + setOpen(false); + defaults.onClose(); + }} + /> + ); + } + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + const screen = await render(, { wrapper: Wrapper }); + return { screen, props: defaults }; +} + +describe("MediaFolderDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("autofocuses Name and creates a trimmed folder on Enter", async () => { + const onCreate = vi.fn().mockResolvedValue({ id: "folder-1", name: "Created" }); + const onClose = vi.fn(); + const { screen } = await renderDialog({ onCreate, onClose }); + const name = screen.getByLabelText("Name"); + + await expect.element(name).toHaveFocus(); + expect(screen.getByText("Create a folder in the Main library.").query()).toBeNull(); + await name.fill(" Created "); + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onCreate).toHaveBeenCalledWith("Created"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("keeps validation and conflict errors inline", async () => { + const onCreate = vi + .fn() + .mockRejectedValue( + new ApiResponseError(409, "CONFLICT", "Database unique constraint failed"), + ); + const { screen } = await renderDialog({ onCreate }); + + screen.getByRole("button", { name: "Create" }).element().click(); + await expect + .element(screen.getByText("Folder name must be between 1 and 200 characters")) + .toBeInTheDocument(); + + await screen.getByLabelText("Name").fill("Duplicate"); + screen.getByRole("button", { name: "Create" }).element().click(); + await expect + .element(screen.getByText("A media folder with this name already exists")) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("ignores duplicate submits while a folder save is pending", async () => { + let resolveCreate: ((folder: { id: string; name: string }) => void) | undefined; + const onCreate = vi.fn( + () => + new Promise<{ id: string; name: string }>((resolve) => { + resolveCreate = resolve; + }), + ); + const { screen } = await renderDialog({ onCreate }); + await screen.getByLabelText("Name").fill("Created"); + const create = screen.getByRole("button", { name: "Create" }).element(); + + create.click(); + create.click(); + + await vi.waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + resolveCreate?.({ id: "folder-1", name: "Created" }); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("renames a folder from the edit dialog", async () => { + const folder = { id: "folder-1", name: "Drafts" }; + const onRename = vi.fn().mockResolvedValue({ ...folder, name: "Published" }); + const { screen } = await renderDialog({ folder, onRename }); + + await screen.getByLabelText("Name").fill("Published"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => expect(onRename).toHaveBeenCalledWith(folder, "Published")); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Edit folder" }).query()).toBeNull(), + ); + }); + + it("explains safe deletion and leaves edit open when confirmation is canceled", async () => { + const folder = { id: "folder-1", name: "Drafts" }; + const onDelete = vi.fn().mockResolvedValue(undefined); + const { screen } = await renderDialog({ folder, onDelete }); + const deleteButton = screen.getByRole("button", { name: "Delete folder" }); + + deleteButton.element().click(); + await expect.element(screen.getByText("Delete “Drafts”?")).toBeInTheDocument(); + await expect + .element( + screen.getByText( + "Media in this folder will return to Main library. No files will be deleted.", + ), + ) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).last().element().click(); + + expect(onDelete).not.toHaveBeenCalled(); + await expect.element(screen.getByRole("heading", { name: "Edit folder" })).toBeInTheDocument(); + await vi.waitFor(() => expect(document.activeElement).toBe(deleteButton.element())); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Edit folder" }).query()).toBeNull(), + ); + }); +}); diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index acaa4d564b..178b4be764 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -1,12 +1,71 @@ +import { Toasty } from "@cloudflare/kumo"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaLibrary } from "../../src/components/MediaLibrary"; -import type { MediaItem, MediaProviderItem } from "../../src/lib/api"; -import { deleteMedia } from "../../src/lib/api"; +import type { LocalMediaItem, MediaFolder, MediaItem, MediaProviderItem } from "../../src/lib/api"; +import { ApiResponseError, deleteMedia } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; +const dndState = vi.hoisted(() => ({ + props: null as null | { + onDragStart?: (event: any) => void; + onDragEnd?: (event: any) => void; + onDragCancel?: (event: any) => void; + }, +})); + +vi.mock("@dnd-kit/core", async () => { + const ReactModule = await import("react"); + return { + DndContext: (props: React.PropsWithChildren>) => { + dndState.props = props as typeof dndState.props; + return ReactModule.createElement(ReactModule.Fragment, null, props.children); + }, + DragOverlay: ({ children }: React.PropsWithChildren) => + ReactModule.createElement(ReactModule.Fragment, null, children), + PointerSensor: Symbol("PointerSensor"), + pointerWithin: () => [], + useSensor: () => ({}), + useSensors: (...sensors: unknown[]) => sensors, + useDraggable: () => ({ + setNodeRef: () => undefined, + listeners: undefined, + isDragging: false, + }), + useDroppable: () => ({ + setNodeRef: () => undefined, + isOver: false, + }), + }; +}); + +vi.mock("../../src/components/RouterLinkButton.js", () => ({ + RouterLinkButton: ({ + to, + search, + variant: _variant, + size: _size, + shape: _shape, + icon: _icon, + ...props + }: React.ComponentProps<"a"> & { + to: string; + search?: { folder?: string }; + variant?: string; + size?: string; + shape?: string; + icon?: React.ReactNode; + }) => ( + + ), +})); + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -29,6 +88,20 @@ function dropFiles(target: EventTarget, files: File[]) { target.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true })); } +async function simulateMediaDrop(item: LocalMediaItem, folder: MediaFolder) { + const active = { data: { current: { kind: "local-media", item } } }; + dndState.props?.onDragStart?.({ active }); + dndState.props?.onDragEnd?.({ + active, + over: { data: { current: { kind: "media-folder-target", folder } } }, + }); + window.dispatchEvent(new PointerEvent("pointerup")); + document + .querySelector("[data-media-library]") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { @@ -45,7 +118,11 @@ function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - return {children}; + return ( + + {children} + + ); } function renderLibrary(props: Partial> = {}) { @@ -78,6 +155,20 @@ function makeMediaItem(overrides: Partial = {}): MediaItem { }; } +function makeLocalMediaItem(overrides: Partial = {}): LocalMediaItem { + return { + ...makeMediaItem(), + storageKey: "media_01.jpg", + authorId: "user_01", + folderId: null, + ...overrides, + }; +} + +function makeFolder(overrides: Partial = {}): MediaFolder { + return { id: "folder-1", name: "Product photos", ...overrides }; +} + function makePagination( overrides: Partial["pagination"]>> = {}, ): NonNullable["pagination"]> { @@ -95,9 +186,587 @@ function makePagination( describe("MediaLibrary", () => { beforeEach(() => { vi.clearAllMocks(); + dndState.props = null; }); describe("rendering items", () => { + it("keeps direct consumers provider-safe when drag feedback is enabled", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const screen = await render( + + true} + onMoveMedia={vi.fn().mockResolvedValue(undefined)} + /> + , + ); + + await expect + .element(screen.getByRole("heading", { name: "Media Library" })) + .toBeInTheDocument(); + }); + + it("uses the concise local upload action without changing the dialog title", async () => { + const screen = await renderLibrary({ items: [makeMediaItem()] }); + + expect(screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).query()).toBeNull(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).element().click(); + await expect + .element(screen.getByRole("heading", { name: "Upload to Library" })) + .toBeInTheDocument(); + }); + + it("renders folders before media with navigation, edit, and load-more actions", async () => { + const onOpenFolder = vi.fn(); + const onCreateFolder = vi.fn().mockResolvedValue(makeFolder()); + const onRenameFolder = vi.fn().mockResolvedValue(makeFolder()); + const onDeleteFolder = vi.fn().mockResolvedValue(undefined); + const onLoadMoreFolders = vi.fn(); + const folder = makeFolder(); + const screen = await renderLibrary({ + folders: [folder], + items: [makeMediaItem()], + pagination: makePagination(), + canManageFolders: true, + hasMoreFolders: true, + onOpenFolder, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onLoadMoreFolders, + }); + + await expect.element(screen.getByRole("heading", { name: "Folders" })).toBeInTheDocument(); + await expect.element(screen.getByText("1 folder loaded")).toBeInTheDocument(); + const folderLink = screen.getByRole("link", { name: "Open folder Product photos" }); + const folderCard = folderLink.element().closest("[data-media-folder-card]"); + expect(folderCard).not.toBeNull(); + expect(folderCard!.getBoundingClientRect().height).toBeLessThanOrEqual(72); + expect(folderLink.element().querySelector('[dir="auto"]')).toHaveTextContent( + "Product photos", + ); + await folderLink.click(); + expect(onOpenFolder).toHaveBeenCalledWith(folder); + await expect.element(screen.getByRole("heading", { name: "Media Library" })).toHaveFocus(); + const editFolder = screen.getByRole("button", { name: "Edit folder Product photos" }); + await editFolder.click(); + await expect + .element(screen.getByRole("heading", { name: "Edit folder" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => expect(document.activeElement).toBe(editFolder.element())); + await screen.getByRole("button", { name: "Add new folder" }).click(); + await expect + .element(screen.getByRole("heading", { name: "Add new folder" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await screen.getByRole("button", { name: "Load more folders" }).click(); + expect(onLoadMoreFolders).toHaveBeenCalledTimes(1); + }); + + it("moves a permitted local item from grid and list without opening another control", async () => { + const folder = makeFolder(); + const item = makeLocalMediaItem(); + const onMoveMedia = vi.fn().mockResolvedValue(undefined); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folders: [folder], + items: [item], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia, + onOpenFolder, + }); + + const gridSource = screen.getByRole("button", { name: "photo.jpg" }).element(); + expect(gridSource.querySelector("img")).toHaveAttribute("draggable", "false"); + const gridTarget = screen + .getByRole("link", { name: "Open folder Product photos" }) + .element() + .closest("[data-media-folder-card]"); + expect(gridTarget).not.toBeNull(); + expect(gridSource).toHaveAttribute("data-media-draggable", "true"); + await simulateMediaDrop(item, folder); + await vi.waitFor(() => expect(onMoveMedia).toHaveBeenCalledWith(item, folder)); + expect(onOpenFolder).not.toHaveBeenCalled(); + expect(screen.getByRole("heading", { name: "Media Details" }).query()).toBeNull(); + await expect + .element(screen.getByText("Moved to Product photos", { exact: true })) + .toBeVisible(); + const successToast = screen + .getByRole("dialog", { name: "Moved to Product photos" }) + .element(); + expect(successToast.querySelector("[data-toast-icon]")).not.toBeNull(); + + await screen.getByRole("tab", { name: "List view" }).click(); + const listSource = [...screen.getByRole("table").element().querySelectorAll("tr")].find( + (row) => row.textContent?.includes("photo.jpg"), + ); + const listTarget = screen + .getByRole("link", { name: "Open folder Product photos" }) + .element() + .closest("tr"); + expect(listSource).not.toBeNull(); + expect(listTarget).not.toBeNull(); + expect(listSource).toHaveAttribute("data-media-draggable", "true"); + await simulateMediaDrop(item, folder); + await vi.waitFor(() => expect(onMoveMedia).toHaveBeenCalledTimes(2)); + expect(onOpenFolder).not.toHaveBeenCalled(); + }); + + it("uses a Phosphor file icon in the drag preview", async () => { + const item = makeLocalMediaItem(); + await renderLibrary({ + folders: [makeFolder()], + items: [item], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia: vi.fn().mockResolvedValue(undefined), + }); + const active = { data: { current: { kind: "local-media", item } } }; + dndState.props?.onDragStart?.({ active }); + + await vi.waitFor(() => { + const overlay = document.querySelector("[data-media-drag-overlay]"); + expect(overlay).not.toBeNull(); + expect(overlay?.querySelector("svg")).not.toBeNull(); + expect(overlay).toHaveTextContent("photo.jpg"); + }); + dndState.props?.onDragCancel?.({ active }); + }); + + it("keeps the ordinary media click available when drag is eligible", async () => { + const onMoveMedia = vi.fn().mockResolvedValue(undefined); + const screen = await renderLibrary({ + folders: [makeFolder()], + items: [makeLocalMediaItem()], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia, + }); + const source = screen.getByRole("button", { name: "photo.jpg" }).element(); + + source.click(); + + expect(onMoveMedia).not.toHaveBeenCalled(); + await expect + .element(screen.getByRole("heading", { name: "Media Details" })) + .toBeInTheDocument(); + }); + + it("cancels drag safely and ignores the current folder and read-only media", async () => { + const folder = makeFolder(); + const onMoveMedia = vi.fn().mockResolvedValue(undefined); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folders: [folder], + items: [makeLocalMediaItem()], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia, + onOpenFolder, + }); + const folderLink = screen.getByRole("link", { name: "Open folder Product photos" }); + const active = { data: { current: { kind: "local-media", item: makeLocalMediaItem() } } }; + dndState.props?.onDragStart?.({ active }); + dndState.props?.onDragCancel?.({ active }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onMoveMedia).not.toHaveBeenCalled(); + expect(onOpenFolder).not.toHaveBeenCalled(); + window.dispatchEvent(new PointerEvent("pointerup")); + const releaseClick = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + }); + folderLink.element().dispatchEvent(releaseClick); + expect(releaseClick.defaultPrevented).toBe(true); + expect(onOpenFolder).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 1)); + const normalClick = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + }); + folderLink.element().dispatchEvent(normalClick); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + onOpenFolder.mockClear(); + dndState.props?.onDragStart?.({ active }); + window.dispatchEvent(new Event("blur")); + folderLink + .element() + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + + await screen.rerender( + + true} + onMoveMedia={onMoveMedia} + /> + , + ); + await simulateMediaDrop(makeLocalMediaItem({ folderId: folder.id }), folder); + expect(onMoveMedia).not.toHaveBeenCalled(); + + await screen.rerender( + + false} + onMoveMedia={onMoveMedia} + /> + , + ); + await simulateMediaDrop(makeLocalMediaItem(), folder); + expect(onMoveMedia).not.toHaveBeenCalled(); + }); + + it("prevents duplicate moves while pending and permits retry after localized failure", async () => { + const folder = makeFolder(); + const item = makeLocalMediaItem(); + let resolveMove: (() => void) | undefined; + const pendingMove = new Promise((resolve) => { + resolveMove = resolve; + }); + const onMoveMedia = vi.fn().mockReturnValueOnce(pendingMove); + const screen = await renderLibrary({ + folders: [folder], + items: [item], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia, + }); + await simulateMediaDrop(item, folder); + await vi.waitFor(() => expect(onMoveMedia).toHaveBeenCalledTimes(1)); + await simulateMediaDrop(item, folder); + expect(onMoveMedia).toHaveBeenCalledTimes(1); + resolveMove?.(); + await expect + .element(screen.getByText("Moved to Product photos", { exact: true })) + .toBeInTheDocument(); + + onMoveMedia.mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + await simulateMediaDrop(item, folder); + await expect + .element(screen.getByText("Couldn’t move file", { exact: true })) + .toBeInTheDocument(); + await expect + .element(screen.getByText("The file or folder no longer exists.", { exact: true })) + .toBeInTheDocument(); + expect( + screen + .getByRole("dialog", { name: "Couldn’t move file" }) + .element() + .querySelector("[data-toast-icon]"), + ).not.toBeNull(); + expect(screen.getByText("Media folder not found").query()).toBeNull(); + + onMoveMedia.mockResolvedValueOnce(undefined); + await simulateMediaDrop(item, folder); + await vi.waitFor(() => expect(onMoveMedia).toHaveBeenCalledTimes(3)); + }); + + it("keeps folder rows before media in list view", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + canManageFolders: true, + onCreateFolder: vi.fn().mockResolvedValue(makeFolder()), + onRenameFolder: vi.fn().mockResolvedValue(makeFolder()), + onDeleteFolder: vi.fn().mockResolvedValue(undefined), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + const rows = screen.getByRole("row").all(); + const folderRow = rows[1]?.element(); + expect(folderRow).toHaveTextContent("Product photos"); + const folderCells = folderRow?.querySelectorAll("td"); + const folderLink = screen.getByRole("link", { name: "Open folder Product photos" }); + const editFolder = screen.getByRole("button", { name: "Edit folder Product photos" }); + expect(folderCells?.[1]).toContainElement(editFolder.element()); + const folderLinkBox = folderLink.element().getBoundingClientRect(); + const editFolderBox = editFolder.element().getBoundingClientRect(); + expect(editFolderBox.left - folderLinkBox.right).toBeLessThanOrEqual(8); + expect(folderLink.element().querySelector('[dir="auto"]')).toHaveTextContent( + "Product photos", + ); + expect(folderCells?.[2]).toHaveTextContent("Type: Folder"); + expect(folderCells?.[3]).toHaveTextContent("Size is not applicable to folders"); + expect(folderCells?.[4]).toHaveTextContent("Alt text is not applicable to folders"); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + }); + + it("renders the initial folder loader and error inside the list table", async () => { + const onRetryFolders = vi.fn(); + const screen = await renderLibrary({ + foldersLoading: true, + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + onRetryFolders, + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + let rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Loading folders"); + expect(rows[1]?.element().querySelector("td")).toHaveAttribute("colspan", "5"); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + + await screen.rerender( + + + , + ); + rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Folders could not be loaded."); + expect(rows[1]?.element().querySelector("td")).toHaveAttribute("colspan", "5"); + await screen.getByRole("button", { name: "Retry" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + }); + + it("orders later folder-page errors and load more before media rows", async () => { + const onRetryFolders = vi.fn(); + const onLoadMoreFolders = vi.fn(); + const screen = await renderLibrary({ + folders: [makeFolder()], + foldersError: new Error("offline"), + hasMoreFolders: true, + onRetryFolders, + onLoadMoreFolders, + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + const rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Product photos"); + expect(rows[2]?.element()).toHaveTextContent("Folders could not be loaded."); + expect(rows[3]?.element()).toHaveTextContent("Load more folders"); + expect(rows[4]?.element()).toHaveTextContent("photo.jpg"); + await screen.getByRole("button", { name: "Retry" }).click(); + await screen.getByRole("button", { name: "Load more folders" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + expect(onLoadMoreFolders).toHaveBeenCalledTimes(1); + }); + + it("shows folders instead of the whole-library empty state", async () => { + const screen = await renderLibrary({ folders: [makeFolder()], items: [] }); + + await expect.element(screen.getByText("Product photos").first()).toBeInTheDocument(); + expect(screen.getByText("Your media library is empty").query()).toBeNull(); + }); + + it("marks folder results busy while loading another bounded page", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + isLoadingMoreFolders: true, + hasMoreFolders: true, + onLoadMoreFolders: vi.fn(), + }); + + const folderSection = screen + .getByRole("heading", { name: "Folders" }) + .element() + .closest("section"); + expect(folderSection).toHaveAttribute("aria-busy", "true"); + await expect.element(screen.getByText("Loading folders")).toBeInTheDocument(); + }); + + it("shows Back and breadcrumbs inside a folder and hides local creation actions", async () => { + const onBackToMain = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-1", + currentFolder: makeFolder(), + canManageFolders: true, + onBackToMain, + }); + + const back = screen.getByRole("link", { name: "Back" }); + const modifiedClick = new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: true, + }); + back.element().dispatchEvent(modifiedClick); + expect(modifiedClick.defaultPrevented).toBe(false); + expect(onBackToMain).not.toHaveBeenCalled(); + back.element().click(); + expect(onBackToMain).toHaveBeenCalledTimes(1); + await expect.element(screen.getByRole("heading", { name: "Media Library" })).toHaveFocus(); + const currentFolder = screen.getByText("Product photos").first(); + await expect.element(currentFolder).toBeInTheDocument(); + expect(currentFolder.element()).toHaveAttribute("dir", "auto"); + const rootCrumb = screen.getByRole("link", { name: "Media Library" }).first(); + expect(getComputedStyle(rootCrumb.element()).fontSize).toBe( + getComputedStyle(currentFolder.element()).fontSize, + ); + expect(screen.getByRole("button", { name: "Add new folder" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).query()).toBeNull(); + }); + + it("keeps browsing available without folder-management permission", async () => { + const folder = makeFolder(); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folders: [folder], + pagination: makePagination(), + canManageFolders: false, + onOpenFolder, + onCreateFolder: vi.fn(), + onRenameFolder: vi.fn(), + onDeleteFolder: vi.fn(), + }); + + expect(screen.getByRole("button", { name: "Add new folder" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: "Edit folder Product photos" }).query()).toBeNull(); + await screen.getByRole("link", { name: "Open folder Product photos" }).click(); + expect(onOpenFolder).toHaveBeenCalledWith(folder); + }); + + it("hides folders on later pages and while a MIME filter is active", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + items: [makeMediaItem()], + pagination: makePagination({ page: 2, totalCount: 70 }), + }); + + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + await screen.rerender( + + + , + ); + await screen.getByRole("combobox", { name: "Filter by type" }).click(); + await screen.getByRole("option", { name: "Images" }).click(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + }); + + it("hides retained folder query state from a filtered list", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + foldersLoading: true, + foldersError: new Error("offline"), + hasMoreFolders: true, + isLoadingMoreFolders: true, + onLoadMoreFolders: vi.fn(), + onRetryFolders: vi.fn(), + items: [makeMediaItem()], + pagination: makePagination(), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + await screen.getByRole("combobox", { name: "Filter by type" }).click(); + await screen.getByRole("option", { name: "Images" }).click(); + + expect(screen.getByRole("link", { name: "Open folder Product photos" }).query()).toBeNull(); + expect(screen.getByText("Loading folders").query()).toBeNull(); + expect(screen.getByText("Folders could not be loaded.").query()).toBeNull(); + expect(screen.getByRole("button", { name: "Retry" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: "Load more folders" }).query()).toBeNull(); + expect(screen.getByRole("table").element()).not.toHaveAttribute("aria-busy"); + }); + + it("shows global folder results while searching from a named folder", async () => { + const onLocalSearchChange = vi.fn(); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-current", + currentFolder: makeFolder({ id: "folder-current", name: "Current" }), + folders: [makeFolder({ id: "folder-result", name: "Product photos" })], + items: [makeMediaItem()], + pagination: makePagination(), + onLocalSearchChange, + onOpenFolder, + }); + + await screen.getByRole("searchbox", { name: "Search media" }).fill("product"); + await expect.element(screen.getByRole("heading", { name: "Folders" })).toBeInTheDocument(); + await expect + .element(screen.getByRole("link", { name: "Open folder Product photos" })) + .toBeInTheDocument(); + await screen.getByRole("link", { name: "Open folder Product photos" }).click(); + expect(onLocalSearchChange).toHaveBeenLastCalledWith(""); + expect(onOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ id: "folder-result" })); + }); + + it("disables local page-drop upload while inside a folder", async () => { + const onUpload = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-1", + currentFolder: makeFolder(), + onUpload, + }); + + dropFiles(window, [new File(["image"], "dropped.jpg", { type: "image/jpeg" })]); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onUpload).not.toHaveBeenCalled(); + expect(screen.getByText("Drop files to upload").query()).toBeNull(); + }); + + it("keeps media usable when folders fail and offers retry", async () => { + const onRetryFolders = vi.fn(); + const screen = await renderLibrary({ + foldersError: new Error("offline"), + onRetryFolders, + items: [makeMediaItem()], + pagination: makePagination(), + }); + + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Folders could not be loaded."); + await screen.getByRole("button", { name: "Retry" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + await expect.element(screen.getByAltText("photo.jpg")).toBeInTheDocument(); + }); + + it("does not claim the library is empty when folder loading fails", async () => { + const screen = await renderLibrary({ + foldersError: new Error("offline"), + onRetryFolders: vi.fn(), + }); + + await expect.element(screen.getByText("Folders could not be loaded.")).toBeInTheDocument(); + expect(screen.getByText("Your media library is empty").query()).toBeNull(); + }); + + it("does not claim there are no matches when folder search fails", async () => { + const screen = await renderLibrary({ + foldersError: new Error("offline"), + onRetryFolders: vi.fn(), + }); + + await screen.getByRole("searchbox", { name: "Search media" }).fill("missing"); + + await expect.element(screen.getByText("Folders could not be loaded.")).toBeInTheDocument(); + expect(screen.getByText("No matching media").query()).toBeNull(); + expect(screen.getByRole("button", { name: "Clear search" }).query()).toBeNull(); + }); + it("displays media items in grid view by default", async () => { const items = [ makeMediaItem({ id: "1", filename: "image1.jpg" }), @@ -143,7 +812,7 @@ describe("MediaLibrary", () => { const onUpload = vi.fn(); const screen = await renderLibrary({ onUpload }); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) @@ -157,7 +826,7 @@ describe("MediaLibrary", () => { it("opens the same empty dialog from the empty-state action", async () => { const screen = await renderLibrary(); - screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).last().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) @@ -177,7 +846,7 @@ describe("MediaLibrary", () => { (name) => new File([name], name, { type: "image/jpeg" }), ); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) .toBeInTheDocument(); @@ -234,7 +903,7 @@ describe("MediaLibrary", () => { .mockResolvedValue(undefined); const screen = await renderLibrary({ onUpload }); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) .toBeInTheDocument(); @@ -363,7 +1032,7 @@ describe("MediaLibrary", () => { await expect.element(screen.getByText("Your media library is empty")).toBeInTheDocument(); await expect.element(screen.getByText(UPLOAD_CTA_PATTERN)).toBeInTheDocument(); await expect - .element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN })) + .element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).last()) .toBeInTheDocument(); }); }); @@ -446,7 +1115,7 @@ describe("MediaLibrary", () => { await expect .element(screen.getByRole("navigation", { name: "Media pagination" })) .toBeInTheDocument(); - await expect.element(screen.getByRole("status")).toHaveTextContent("Showing 1-35 of 37"); + await expect.element(screen.getByText("Showing 1-35 of 37")).toBeInTheDocument(); expect(screen.getByText("37 items", { exact: true }).query()).toBeNull(); await expect .element(screen.getByRole("combobox", { name: "Page number" })) @@ -575,6 +1244,42 @@ describe("MediaLibrary", () => { await expect.element(screen.getByAltText("restored.jpg")).toBeInTheDocument(); }); + it("does not make provider media draggable into local folders", async () => { + const api = await import("../../src/lib/api"); + (api.fetchMediaProviders as any).mockResolvedValueOnce([ + { + id: "cloudflare-images", + name: "Cloudflare Images", + capabilities: { browse: true, search: false, upload: false, delete: false }, + }, + ]); + (api.fetchProviderMedia as any).mockResolvedValueOnce({ + items: [ + { + id: "provider-1", + filename: "provider.jpg", + mimeType: "image/jpeg", + previewUrl: "https://example.com/provider.jpg", + size: 100, + }, + ], + }); + + const screen = await renderLibrary({ + items: [makeLocalMediaItem()], + folders: [makeFolder()], + pagination: makePagination(), + canMoveMedia: () => true, + onMoveMedia: vi.fn().mockResolvedValue(undefined), + }); + await screen.getByRole("tab", { name: "Cloudflare Images" }).click(); + + const providerCard = screen.getByRole("button", { name: "provider.jpg" }); + await expect.element(providerCard).toBeInTheDocument(); + expect(providerCard.element()).not.toHaveAttribute("data-media-draggable"); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + }); + it("does not keep the local filter toolbar visible on empty provider tabs", async () => { const api = await import("../../src/lib/api"); (api.fetchMediaProviders as any).mockResolvedValueOnce([ @@ -587,6 +1292,7 @@ describe("MediaLibrary", () => { const screen = await renderLibrary({ items: [makeMediaItem({ id: "1", filename: "a.jpg" })], + folders: [makeFolder()], pagination: makePagination(), }); @@ -598,6 +1304,7 @@ describe("MediaLibrary", () => { expect(screen.getByRole("navigation", { name: "Media pagination" }).query()).toBeNull(); expect(screen.getByRole("tab", { name: "Grid view" }).query()).toBeNull(); expect(screen.getByRole("tab", { name: "List view" }).query()).toBeNull(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); }); }); diff --git a/packages/admin/tests/lib/api-client.test.ts b/packages/admin/tests/lib/api-client.test.ts index 14e4af9b31..0c80e0fe5c 100644 --- a/packages/admin/tests/lib/api-client.test.ts +++ b/packages/admin/tests/lib/api-client.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { apiFetch, fetchManifest, throwResponseError } from "../../src/lib/api/client"; +import { + ApiResponseError, + apiFetch, + fetchManifest, + throwResponseError, +} from "../../src/lib/api/client"; describe("apiFetch", () => { let fetchSpy: ReturnType; @@ -132,6 +137,29 @@ describe("throwResponseError", () => { await expect(throwResponseError(response, "fallback")).rejects.toThrow("Not found"); }); + it("preserves status, code, and details on API response errors", async () => { + const response = new Response( + JSON.stringify({ + error: { + code: "CONFLICT", + message: "Already exists", + details: { field: "name" }, + }, + }), + { status: 409 }, + ); + + const error = await throwResponseError(response, "fallback").catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ApiResponseError); + expect(error).toMatchObject({ + status: 409, + code: "CONFLICT", + message: "Already exists", + details: { field: "name" }, + }); + }); + it("falls back to the generic fallback when the body has no error", async () => { const response = new Response("", { status: 500, statusText: "Internal Server Error" }); await expect(throwResponseError(response, "fallback")).rejects.toThrow( diff --git a/packages/admin/tests/lib/media-folders.test.ts b/packages/admin/tests/lib/media-folders.test.ts new file mode 100644 index 0000000000..3ed25ac641 --- /dev/null +++ b/packages/admin/tests/lib/media-folders.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiResponseError } from "../../src/lib/api/client"; +import { + createMediaFolder, + deleteMediaFolder, + fetchMediaFolder, + fetchMediaFolders, + renameMediaFolder, +} from "../../src/lib/api/media"; + +describe("media folder API client", () => { + const originalFetch = globalThis.fetch; + let requests: Request[]; + + beforeEach(() => { + requests = []; + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request.clone()); + const url = new URL(request.url, "http://localhost"); + if (request.method === "GET" && url.pathname.endsWith("/folder%2Fone")) { + return Response.json({ data: { item: { id: "folder/one", name: "One" } } }); + } + if (request.method === "GET") { + return Response.json({ + data: { items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }, + }); + } + if (request.method === "DELETE") return Response.json({ data: { deleted: true } }); + return Response.json({ data: { item: { id: "folder/one", name: "Saved" } } }); + }) as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("lists and gets folders with bounded search and encoded IDs", async () => { + const list = await fetchMediaFolders({ + limit: 100, + cursor: "after / folder", + search: " résumé ", + }); + const folder = await fetchMediaFolder("folder/one"); + + expect(list).toEqual({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); + expect(folder).toEqual({ id: "folder/one", name: "One" }); + const listUrl = new URL(requests[0]!.url); + expect(Object.fromEntries(listUrl.searchParams)).toEqual({ + limit: "100", + cursor: "after / folder", + q: "résumé", + }); + expect(new URL(requests[1]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + }); + + it("creates, renames, and deletes folders with exact request bodies", async () => { + await createMediaFolder("Created"); + await renameMediaFolder("folder/one", "Renamed"); + await deleteMediaFolder("folder/one"); + + expect(requests.map((request) => request.method)).toEqual(["POST", "PUT", "DELETE"]); + expect(await requests[0]!.json()).toEqual({ name: "Created" }); + expect(await requests[1]!.json()).toEqual({ name: "Renamed" }); + expect(new URL(requests[1]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + expect(new URL(requests[2]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + }); + + it("surfaces the server error code and message", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { code: "CONFLICT", message: "A media folder with this name already exists" } }, + { status: 409 }, + ), + ); + + const error = await createMediaFolder("Duplicate").catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ApiResponseError); + expect(error).toMatchObject({ + status: 409, + code: "CONFLICT", + message: "A media folder with this name already exists", + }); + }); +}); diff --git a/packages/admin/tests/lib/media-pagination.test.ts b/packages/admin/tests/lib/media-pagination.test.ts index 73ee3c2483..a0b0cc51ed 100644 --- a/packages/admin/tests/lib/media-pagination.test.ts +++ b/packages/admin/tests/lib/media-pagination.test.ts @@ -9,8 +9,9 @@ describe("media page API client", () => { beforeEach(() => { fetchSpy = vi .fn() - .mockResolvedValue( - new Response(JSON.stringify({ data: { items: [], totalCount: 37 } }), { status: 200 }), + .mockImplementation( + () => + new Response(JSON.stringify({ data: { items: [], totalCount: 37 } }), { status: 200 }), ); globalThis.fetch = fetchSpy as typeof globalThis.fetch; }); @@ -27,4 +28,13 @@ describe("media page API client", () => { expect(Object.fromEntries(requestUrl.searchParams)).toEqual({ page: "1", limit: "35" }); expect(result).toEqual({ items: [], totalCount: 37 }); }); + + it("serializes Main library and named-folder filters", async () => { + const mainOptions = { page: 1, limit: 35, folderId: null }; + await fetchMediaList(mainOptions); + await fetchMediaList({ page: 1, limit: 35, folderId: "folder/one" }); + + const urls = fetchSpy.mock.calls.map(([url]) => new URL(url, "http://localhost")); + expect(urls.map((url) => url.searchParams.get("folderId"))).toEqual(["unfiled", "folder/one"]); + }); }); diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 73587a26be..0a56747bb4 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -115,12 +115,41 @@ vi.mock("../src/components/MediaLibrary", () => ({ isLoading, onUpload, onLocalSearchChange, + folders, + hasMoreFolders, + onLoadMoreFolders, + folderId, + currentFolder, + canManageFolders, + onOpenFolder, + onBackToMain, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + canMoveMedia, + onMoveMedia, pagination, }: { items?: Array<{ id?: string }>; isLoading?: boolean; onUpload?: (file: File) => Promise | void; onLocalSearchChange?: (search: string) => void; + folders?: Array<{ id: string }>; + hasMoreFolders?: boolean; + onLoadMoreFolders?: () => void; + folderId?: string; + currentFolder?: { id: string; name: string } | null; + canManageFolders?: boolean; + onOpenFolder?: (folder: { id: string; name: string }) => void; + onBackToMain?: () => void; + onCreateFolder?: (name: string) => Promise; + onRenameFolder?: (folder: { id: string; name: string }, name: string) => Promise; + onDeleteFolder?: (folder: { id: string; name: string }) => Promise; + canMoveMedia?: (item: { authorId: string | null }) => boolean; + onMoveMedia?: ( + item: { id: string; authorId: string | null }, + folder: { id: string; name: string }, + ) => Promise; pagination?: { page: number; perPage: number; @@ -129,6 +158,7 @@ vi.mock("../src/components/MediaLibrary", () => ({ }; }) => { const [uploadStatus, setUploadStatus] = React.useState("idle"); + const [moveStatus, setMoveStatus] = React.useState("move-ready"); const upload = async () => { setUploadStatus("uploading"); @@ -139,6 +169,18 @@ vi.mock("../src/components/MediaLibrary", () => ({ setUploadStatus("error"); } }; + const moveMedia = async () => { + setMoveStatus("moving"); + try { + await onMoveMedia?.( + { id: "media_01", authorId: "user_01" }, + { id: "folder-one", name: "Folder One" }, + ); + setMoveStatus("moved"); + } catch { + setMoveStatus("move-error"); + } + }; return (
@@ -149,6 +191,46 @@ vi.mock("../src/components/MediaLibrary", () => ({ {items?.length ?? 0} {items?.[0]?.id ?? ""} {isLoading ? "loading" : "ready"} + {folders?.length ?? 0} + + + + {canManageFolders && ( + <> + + + + + )} + {folderId ?? "main"} + + {canMoveMedia?.({ authorId: "user_01" }) ? "yes" : "no"} + + + {canMoveMedia?.({ authorId: "other-user" }) ? "yes" : "no"} + + + {moveStatus} {pagination && ( <> {pagination.page} @@ -247,6 +329,36 @@ describe("MediaPage – upload completion", () => { .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 60 }, }) + .on("GET", "/_emdash/api/media/folders/folder-one", { + data: { item: { id: "folder-one", name: "Folder One" } }, + }) + .on("GET", "/_emdash/api/media/folders", { + data: { items: [{ id: "folder-one", name: "Folder One" }] }, + }) + .on("POST", "/_emdash/api/media/folders", { + data: { item: { id: "folder-created", name: "Created" } }, + }) + .on("PUT", "/_emdash/api/media/folders/folder-one", { + data: { item: { id: "folder-one", name: "Renamed" } }, + }) + .on("DELETE", "/_emdash/api/media/folders/folder-one", { + data: { deleted: true }, + }) + .on("PUT", "/_emdash/api/media/media_01", { + data: { + item: { + id: "media_01", + filename: "photo.jpg", + mimeType: "image/jpeg", + url: "/media/photo.jpg", + storageKey: "photo.jpg", + size: 1, + createdAt: "2025-01-01T00:00:00Z", + authorId: "user_01", + folderId: "folder-one", + }, + }, + }) .on("GET", "/_emdash/api/media", { data: { items: [], totalCount: 60 }, }); @@ -294,6 +406,176 @@ describe("MediaPage – upload completion", () => { } }); + it("persists a media folder move and refreshes media before resolving", async () => { + const requests: Array<{ method: string; path: string }> = []; + const mockedFetch = globalThis.fetch; + let movePersisted = false; + let mediaRefreshStarted = false; + let releaseMediaRefresh: () => void = () => undefined; + const mediaRefreshGate = new Promise((resolve) => { + releaseMediaRefresh = resolve; + }); + globalThis.fetch = async (input, init) => { + const rawUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const path = new URL(rawUrl, "http://localhost").pathname; + const method = init?.method ?? "GET"; + requests.push({ + method, + path, + }); + if (movePersisted && method === "GET" && path === "/_emdash/api/media") { + mediaRefreshStarted = true; + await mediaRefreshGate; + } + const response = await mockedFetch(input, init); + if (method === "PUT" && path === "/_emdash/api/media/media_01") movePersisted = true; + return response; + }; + + try { + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-ready"); + const mediaReadsBefore = requests.filter( + (request) => request.method === "GET" && request.path === "/_emdash/api/media", + ).length; + + await screen.getByRole("button", { name: "Move mock media" }).click(); + await vi.waitFor(() => expect(mediaRefreshStarted).toBe(true)); + expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving"); + releaseMediaRefresh(); + await expect.element(screen.getByTestId("move-status")).toHaveTextContent("moved"); + expect( + requests.some( + (request) => request.method === "PUT" && request.path === "/_emdash/api/media/media_01", + ), + ).toBe(true); + expect( + requests.filter( + (request) => request.method === "GET" && request.path === "/_emdash/api/media", + ).length, + ).toBeGreaterThan(mediaReadsBefore); + } finally { + releaseMediaRefresh(); + globalThis.fetch = mockedFetch; + } + }); + + it("waits for stale media and folder recovery after a missing move target", async () => { + const mockedFetch = globalThis.fetch; + let recovering = false; + let releaseRecovery: () => void = () => undefined; + const recoveryGate = new Promise((resolve) => { + releaseRecovery = resolve; + }); + const recoveryReads = new Set(); + globalThis.fetch = async (input, init) => { + const rawUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname === "/_emdash/api/media/media_01" && init?.method === "PUT") { + recovering = true; + return new Response( + JSON.stringify({ + success: false, + error: { code: "NOT_FOUND", message: "Media folder not found" }, + }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + if ( + recovering && + init?.method !== "PUT" && + (url.pathname === "/_emdash/api/media" || + url.pathname === "/_emdash/api/media/folders" || + url.pathname === "/_emdash/api/media/folders/folder-one") + ) { + recoveryReads.add(url.pathname); + await recoveryGate; + } + return mockedFetch(input, init); + }; + + try { + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media", search: { folder: "folder-one" } }); + const screen = await render(); + await screen.getByRole("button", { name: "Search media" }).click(); + await screen.getByRole("button", { name: "Move mock media" }).click(); + await expect.element(screen.getByTestId("move-status")).toHaveTextContent("moving"); + await vi.waitFor(() => { + expect(recoveryReads).toEqual( + new Set([ + "/_emdash/api/media", + "/_emdash/api/media/folders", + "/_emdash/api/media/folders/folder-one", + ]), + ); + }); + expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving"); + releaseRecovery(); + await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-error"); + } finally { + releaseRecovery(); + globalThis.fetch = mockedFetch; + } + }); + + it("clears cached move eligibility while recovering from authorization failure", async () => { + const mockedFetch = globalThis.fetch; + let recovering = false; + let releaseCurrentUser: () => void = () => undefined; + const currentUserGate = new Promise((resolve) => { + releaseCurrentUser = resolve; + }); + let currentUserRecoveryStarted = false; + globalThis.fetch = async (input, init) => { + const rawUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname === "/_emdash/api/media/media_01" && init?.method === "PUT") { + recovering = true; + return new Response( + JSON.stringify({ + success: false, + error: { code: "FORBIDDEN", message: "Permission denied" }, + }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + if (recovering && url.pathname === "/_emdash/api/auth/me") { + currentUserRecoveryStarted = true; + await currentUserGate; + return new Response( + JSON.stringify({ + success: false, + error: { code: "INVALID_TOKEN", message: "Invalid token" }, + }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + } + return mockedFetch(input, init); + }; + + try { + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("yes"); + await screen.getByRole("button", { name: "Move mock media" }).click(); + await vi.waitFor(() => expect(currentUserRecoveryStarted).toBe(true)); + expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving"); + releaseCurrentUser(); + await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-error"); + await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("no"); + } finally { + releaseCurrentUser(); + globalThis.fetch = mockedFetch; + } + }); + it("requests numbered pages and resets page state for page size and search", async () => { const requests: string[] = []; const mockedFetch = globalThis.fetch; @@ -333,6 +615,231 @@ describe("MediaPage – upload completion", () => { }); }); + it("maps root and direct folder URL state to media filters with global search precedence", async () => { + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await vi.waitFor(() => { + expect( + requests.some( + (url) => + url.includes("/_emdash/api/media?") && + new URL(url, "http://localhost").searchParams.get("folderId") === "unfiled", + ), + ).toBe(true); + }); + + await router.navigate({ to: "/media", search: { folder: "folder-one" } }); + await vi.waitFor(() => { + expect(requests.some((url) => url.includes("/media/folders/folder-one"))).toBe(true); + expect( + requests.some( + (url) => new URL(url, "http://localhost").searchParams.get("folderId") === "folder-one", + ), + ).toBe(true); + }); + + await screen.getByRole("button", { name: "Search media" }).click(); + await vi.waitFor(() => { + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return url.searchParams.get("q") === "photo" && !url.searchParams.has("folderId"); + }), + ).toBe(true); + }); + }); + + it("does not request the previous page when direct folder state changes", async () => { + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await screen.getByRole("button", { name: "Open page 2" }).click(); + await expect.element(screen.getByTestId("media-page")).toHaveTextContent("2"); + + requests.length = 0; + await router.navigate({ to: "/media", search: { folder: "folder-one" } }); + await vi.waitFor(() => { + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return ( + url.searchParams.get("folderId") === "folder-one" && + url.searchParams.get("page") === "1" + ); + }), + ).toBe(true); + }); + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return ( + url.searchParams.get("folderId") === "folder-one" && url.searchParams.get("page") === "2" + ); + }), + ).toBe(false); + }); + + it("loads bounded folder pages and exposes explicit load-more state", async () => { + const mockedFetch = globalThis.fetch; + const folderRequests: URL[] = []; + globalThis.fetch = (input, init) => { + const rawUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname === "/_emdash/api/media/folders") { + folderRequests.push(url); + const cursor = url.searchParams.get("cursor"); + return Promise.resolve( + Response.json({ + data: + cursor === "next-folder" + ? { items: [{ id: "folder-two", name: "Folder Two" }] } + : { + items: [{ id: "folder-one", name: "Folder One" }], + nextCursor: "next-folder", + }, + }), + ); + } + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1"); + await screen.getByRole("button", { name: "Load more folders" }).click(); + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("2"); + expect(folderRequests).toHaveLength(2); + expect(folderRequests[0]?.searchParams.get("limit")).toBe("100"); + expect(folderRequests[1]?.searchParams.get("cursor")).toBe("next-folder"); + }); + + it("orchestrates create, open, rename, and current-folder delete", async () => { + const calls: Array<{ url: string; method: string; body?: string }> = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + calls.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : undefined, + }); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const navigateSpy = vi.spyOn(router, "navigate"); + const screen = await render(); + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1"); + + await screen.getByRole("button", { name: "Create mock folder" }).click(); + await vi.waitFor(() => { + const request = calls.find((call) => call.method === "POST" && call.url.endsWith("/folders")); + expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Created" }); + }); + + await screen.getByRole("button", { name: "Open mock folder" }).click(); + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({ folder: "folder-one" }); + expect(screen.getByTestId("current-folder-id").element()).toHaveTextContent("folder-one"); + expect(navigateSpy).toHaveBeenCalledWith(expect.objectContaining({ resetScroll: false })); + }); + + await screen.getByRole("button", { name: "Back to Main" }).click(); + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({}); + expect(navigateSpy).toHaveBeenCalledWith( + expect.objectContaining({ search: { folder: undefined }, resetScroll: false }), + ); + }); + await screen.getByRole("button", { name: "Open mock folder" }).click(); + + await screen.getByRole("button", { name: "Rename current folder" }).click(); + await vi.waitFor(() => { + const request = calls.find( + (call) => call.method === "PUT" && call.url.endsWith("/folders/folder-one"), + ); + expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Renamed" }); + }); + + await screen.getByRole("button", { name: "Delete current folder" }).click(); + await vi.waitFor(() => { + expect( + calls.some((call) => call.method === "DELETE" && call.url.endsWith("/folders/folder-one")), + ).toBe(true); + expect(router.state.location.search).toEqual({}); + expect(navigateSpy).toHaveBeenCalledWith( + expect.objectContaining({ replace: true, resetScroll: false }), + ); + }); + }); + + it("allows authors to move their own media but not another user's media", async () => { + mockFetch.on("GET", "/_emdash/api/auth/me", { + data: { id: "user_01", role: 30 }, + }); + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("yes"); + await expect.element(screen.getByTestId("can-move-other-media")).toHaveTextContent("no"); + }); + + it("replaces a missing direct folder URL with Main library once", async () => { + mockFetch.on( + "GET", + "/_emdash/api/media/folders/missing-folder", + { error: { code: "NOT_FOUND", message: "Media folder not found" } }, + 404, + ); + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media", search: { folder: "missing-folder" } }); + const screen = await render(); + + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({}); + expect( + requests.some( + (rawUrl) => + new URL(rawUrl, "http://localhost").searchParams.get("folderId") === "unfiled", + ), + ).toBe(true); + }); + await expect.element(screen.getByText("Folder no longer exists")).toBeInTheDocument(); + }); + it("recovers an emptied later page without exposing an invalid page number", async () => { const mockedFetch = globalThis.fetch; let requestedSecondPage = false; diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 660521dc26..70e61e6dbe 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -87,6 +87,7 @@ export const ErrorCode = { MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR", MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", MEDIA_FOLDER_LIST_ERROR: "MEDIA_FOLDER_LIST_ERROR", + MEDIA_FOLDER_GET_ERROR: "MEDIA_FOLDER_GET_ERROR", MEDIA_FOLDER_CREATE_ERROR: "MEDIA_FOLDER_CREATE_ERROR", MEDIA_FOLDER_UPDATE_ERROR: "MEDIA_FOLDER_UPDATE_ERROR", MEDIA_FOLDER_DELETE_ERROR: "MEDIA_FOLDER_DELETE_ERROR", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 15e9833ba8..be8049b287 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -62,6 +62,7 @@ export { } from "./media.js"; export { handleMediaFolderList, + handleMediaFolderGet, handleMediaFolderCreate, handleMediaFolderUpdate, handleMediaFolderDelete, diff --git a/packages/core/src/api/handlers/media-folders.ts b/packages/core/src/api/handlers/media-folders.ts index 0d34167ff1..7374479590 100644 --- a/packages/core/src/api/handlers/media-folders.ts +++ b/packages/core/src/api/handlers/media-folders.ts @@ -13,7 +13,7 @@ const UNIQUE_VIOLATION_RE = export async function handleMediaFolderList( db: Kysely, - options: { limit?: number; cursor?: string } = {}, + options: { limit?: number; cursor?: string; q?: string } = {}, ): Promise> { try { const result = await new MediaFolderRepository(db).findMany(options); @@ -29,6 +29,24 @@ export async function handleMediaFolderList( } } +export async function handleMediaFolderGet( + db: Kysely, + id: string, +): Promise> { + try { + const item = await new MediaFolderRepository(db).findById(id); + if (!item) { + return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } }; + } + return { success: true, data: { item } }; + } catch { + return { + success: false, + error: { code: "MEDIA_FOLDER_GET_ERROR", message: "Failed to get media folder" }, + }; + } +} + export async function handleMediaFolderCreate( db: Kysely, input: { name: string }, diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index e76a23ce47..992d877e91 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -742,6 +742,24 @@ function buildMediaPaths(maxUploadSize: number) { }, }, "/_emdash/api/media/folders/{id}": { + get: { + operationId: "getMediaFolder", + summary: "Get a media folder", + tags: ["Media"], + requestParams: { + path: z.object({ id: mediaFolderIdSchema.meta({ description: "Media folder ID" }) }), + }, + responses: { + "200": { + description: "Media folder", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaFolderResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, put: { operationId: "updateMediaFolder", summary: "Update a media folder", diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 9c36d6aaa5..75859fbfd3 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -70,7 +70,9 @@ export const mediaUpdateBody = z export const mediaFolderIdSchema = z.string().min(1).max(64); -export const mediaFolderListQuery = cursorPaginationQuery.meta({ id: "MediaFolderListQuery" }); +export const mediaFolderListQuery = cursorPaginationQuery + .extend({ q: z.string().trim().min(1).max(200).optional() }) + .meta({ id: "MediaFolderListQuery" }); const mediaFolderNameSchema = z.string().refine( (value) => { diff --git a/packages/core/src/astro/routes/api/media/folders/[id].ts b/packages/core/src/astro/routes/api/media/folders/[id].ts index 7fa355135d..a0e230ec5d 100644 --- a/packages/core/src/astro/routes/api/media/folders/[id].ts +++ b/packages/core/src/astro/routes/api/media/folders/[id].ts @@ -2,7 +2,11 @@ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; -import { handleMediaFolderDelete, handleMediaFolderUpdate } from "#api/handlers/media-folders.js"; +import { + handleMediaFolderDelete, + handleMediaFolderGet, + handleMediaFolderUpdate, +} from "#api/handlers/media-folders.js"; import { isParseError, parseBody } from "#api/parse.js"; import { mediaFolderBody, mediaFolderIdSchema } from "#api/schemas.js"; @@ -15,6 +19,17 @@ function parseFolderId(id: string | undefined): string | Response { : apiError("VALIDATION_ERROR", "Invalid media folder ID", 400); } +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:read"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const id = parseFolderId(params.id); + if (id instanceof Response) return id; + return unwrapResult(await handleMediaFolderGet(emdash.db, id)); +}; + export const PUT: APIRoute = async ({ params, request, locals }) => { const { emdash, user } = locals; const denied = requirePerm(user, "media:edit_any"); diff --git a/packages/core/src/astro/routes/api/media/folders/index.ts b/packages/core/src/astro/routes/api/media/folders/index.ts index c464d34c96..ef1e496910 100644 --- a/packages/core/src/astro/routes/api/media/folders/index.ts +++ b/packages/core/src/astro/routes/api/media/folders/index.ts @@ -17,7 +17,11 @@ export const GET: APIRoute = async ({ request, locals }) => { const query = parseQuery(new URL(request.url), mediaFolderListQuery); if (isParseError(query)) return query; return unwrapResult( - await handleMediaFolderList(emdash.db, { limit: query.limit, cursor: query.cursor }), + await handleMediaFolderList(emdash.db, { + limit: query.limit, + cursor: query.cursor, + q: query.q, + }), ); }; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 8a23ffc749..cf0d84a5ae 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -862,15 +862,25 @@ export class EmDashClient { /** List media folders */ async mediaFolderList( - options: { limit?: number; cursor?: string } = {}, + options: { limit?: number; cursor?: string; q?: string } = {}, ): Promise> { const params = new URLSearchParams(); if (options.limit !== undefined) params.set("limit", String(options.limit)); if (options.cursor !== undefined) params.set("cursor", options.cursor); + if (options.q !== undefined) params.set("q", options.q); const qs = params.toString(); return this.request>("GET", `/media/folders${qs ? `?${qs}` : ""}`); } + /** Get one media folder */ + async mediaFolderGet(id: string): Promise { + const data = await this.request<{ item: MediaFolder }>( + "GET", + `/media/folders/${encodeURIComponent(id)}`, + ); + return data.item; + } + /** Create a media folder */ async mediaFolderCreate(name: string): Promise { const data = await this.request<{ item: MediaFolder }>("POST", "/media/folders", { name }); diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts index 6b927269b5..3b72439c05 100644 --- a/packages/core/src/database/repositories/media-folders.ts +++ b/packages/core/src/database/repositories/media-folders.ts @@ -1,4 +1,4 @@ -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; import type { Database } from "../types.js"; @@ -12,6 +12,15 @@ export interface MediaFolder { export interface FindManyMediaFoldersOptions { limit?: number; cursor?: string; + q?: string; +} + +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +function normalizeFolderSearch(value: string): string { + return value.trim().normalize("NFKC").toLowerCase(); } function normalizeFolderName(name: string): { name: string; nameKey: string } { @@ -37,6 +46,12 @@ export class MediaFolderRepository { .orderBy("id", "asc") .limit(limit + 1); + const term = normalizeFolderSearch(options.q ?? ""); + if (term) { + const pattern = `%${escapeLike(term)}%`; + query = query.where("name_key", "like", sql`${pattern} escape '\\'`); + } + if (options.cursor !== undefined) { const { orderValue: nameKey, id } = decodeCursor(options.cursor); query = query.where((eb) => diff --git a/packages/core/tests/unit/api/media-folders-handlers.test.ts b/packages/core/tests/unit/api/media-folders-handlers.test.ts index 1c59911728..090e6f8f59 100644 --- a/packages/core/tests/unit/api/media-folders-handlers.test.ts +++ b/packages/core/tests/unit/api/media-folders-handlers.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { handleMediaFolderCreate, handleMediaFolderDelete, + handleMediaFolderGet, handleMediaFolderList, handleMediaFolderUpdate, } from "../../../src/api/handlers/media-folders.js"; @@ -48,6 +49,42 @@ describeEachDialect("media folder handlers", (dialect) => { }); }); + it("normalizes and applies folder-name search before pagination", async () => { + await handleMediaFolderCreate(ctx.db, { name: "Archive" }); + await handleMediaFolderCreate(ctx.db, { name: "Résumé" }); + const options = { q: " re\u0301su " }; + + const result = await handleMediaFolderList(ctx.db, options); + + expect(result).toMatchObject({ + success: true, + data: { items: [{ name: "Résumé" }] }, + }); + }); + + it("treats folder-search wildcards literally", async () => { + await handleMediaFolderCreate(ctx.db, { name: "100% Real" }); + await handleMediaFolderCreate(ctx.db, { name: "100 Percent" }); + + const result = await handleMediaFolderList(ctx.db, { q: "%" }); + + expect(result).toMatchObject({ + success: true, + data: { items: [{ name: "100% Real" }] }, + }); + }); + + it("gets one folder and returns not found for an unknown ID", async () => { + const created = await handleMediaFolderCreate(ctx.db, { name: "Current" }); + if (!created.success) throw new Error("expected folder create success"); + + expect(await handleMediaFolderGet(ctx.db, created.data.item.id)).toEqual(created); + expect(await handleMediaFolderGet(ctx.db, "missing-folder")).toMatchObject({ + success: false, + error: { code: "NOT_FOUND" }, + }); + }); + it("normalizes names and maps create or rename collisions to conflicts", async () => { const created = await handleMediaFolderCreate(ctx.db, { name: " Photos " }); expect(created).toMatchObject({ success: true, data: { item: { name: "Photos" } } }); diff --git a/packages/core/tests/unit/api/media-folders-routes.test.ts b/packages/core/tests/unit/api/media-folders-routes.test.ts index a25e06ff62..55e2d93a5a 100644 --- a/packages/core/tests/unit/api/media-folders-routes.test.ts +++ b/packages/core/tests/unit/api/media-folders-routes.test.ts @@ -5,6 +5,7 @@ import { GET as listMedia } from "../../../src/astro/routes/api/media.js"; import { PUT as updateMedia } from "../../../src/astro/routes/api/media/[id].js"; import { DELETE as deleteFolder, + GET as getFolder, PUT as updateFolder, } from "../../../src/astro/routes/api/media/folders/[id].js"; import { @@ -151,6 +152,30 @@ describe("media folder routes", () => { ).toMatchObject({ status: 200 }); }); + it("allows readers to get one folder and validates direct folder IDs", async () => { + const folder = await new MediaFolderRepository(ctx.db).create("Direct"); + const request = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`); + + const response = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: folder.id }) as Parameters[0], + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ data: { item: folder } }); + + const missing = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: "missing-folder" }) as Parameters< + typeof getFolder + >[0], + ); + expect(missing.status).toBe(404); + + const invalidId = "x".repeat(65); + const invalid = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: invalidId }) as Parameters[0], + ); + expect(invalid.status).toBe(400); + }); + it("maps unfiled list requests to Main library", async () => { const handleMediaList = vi.fn().mockResolvedValue({ success: true, data: { items: [] } }); const request = new Request("http://localhost/_emdash/api/media?folderId=unfiled"); diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index c9d565aa5a..232729adf2 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -65,6 +65,16 @@ describe("OpenAPI document generation", () => { "500": expect.any(Object), }), ); + expect(folders?.get?.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "q", in: "query" })]), + ); + expect(folder?.get?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "404": expect.any(Object), + }), + ); expect(folders?.post?.responses).toEqual( expect.objectContaining({ "201": expect.any(Object), diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index cbe189754b..fb666f25c2 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -751,6 +751,9 @@ describe("EmDashClient", () => { const url = new URL(req.url); const text = await req.text(); requests.push({ method: req.method, url, body: text ? JSON.parse(text) : undefined }); + if (req.method === "GET" && url.pathname.endsWith("/media/folders/folder%2Fone")) { + return jsonResponse({ item: { id: "folder/one", name: "One" } }); + } if (req.method === "GET") { return jsonResponse({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); } @@ -766,7 +769,12 @@ describe("EmDashClient", () => { interceptors: [backend], }); - const list = await client.mediaFolderList({ limit: 25, cursor: "after / folder" }); + const list = await client.mediaFolderList({ + limit: 25, + cursor: "after / folder", + q: "photo set", + }); + const fetched = await client.mediaFolderGet("folder/one"); const created = await client.mediaFolderCreate("Created"); const updated = await client.mediaFolderUpdate("folder/one", "Updated"); await client.mediaFolderDelete("folder/one"); @@ -775,14 +783,21 @@ describe("EmDashClient", () => { expect(list).toEqual({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); expect(created).toEqual({ id: "folder/one", name: "Updated" }); expect(updated).toEqual({ id: "folder/one", name: "Updated" }); + expect(fetched).toEqual({ id: "folder/one", name: "One" }); expect(media).toEqual({ id: "media/one", folderId: null }); expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ limit: "25", cursor: "after / folder", + q: "photo set", }); expect( - requests.slice(1).map(({ method, url, body }) => ({ method, path: url.pathname, body })), + requests.map(({ method, url, body }) => ({ method, path: url.pathname, body })).slice(1), ).toEqual([ + { + method: "GET", + path: "/_emdash/api/media/folders/folder%2Fone", + body: undefined, + }, { method: "POST", path: "/_emdash/api/media/folders", body: { name: "Created" } }, { method: "PUT",
{t`Preview`}
+
+ + {t`Loading folders`} +
+
+ +
+
+
+
+
+ handleNavigationClick(event, onOpen)} + > + + {folder.name} + + + {canEdit && ( + + )} +
+
+ + {t`Type: Folder`} + + + {t`Size is not applicable to folders`} + + + {t`Alt text is not applicable to folders`} +
+
+ {t`Folders could not be loaded.`} + {onRetry && ( + + )} +
+