diff --git a/.changeset/bright-media-pages.md b/.changeset/bright-media-pages.md new file mode 100644 index 0000000000..f04537edf4 --- /dev/null +++ b/.changeset/bright-media-pages.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds numbered page navigation and page-size controls to the local Media Library. Media list requests can opt into numbered pages with `page` and receive an exact `totalCount`; cursor pagination remains the default. + +`MediaLibrary` accepts controlled numbered pagination through `pagination`. Existing `hasMore` and `onLoadMore` props remain supported when `pagination` is omitted. diff --git a/e2e/tests/admin-fixes.spec.ts b/e2e/tests/admin-fixes.spec.ts index f6445e2145..8376343b2f 100644 --- a/e2e/tests/admin-fixes.spec.ts +++ b/e2e/tests/admin-fixes.spec.ts @@ -96,7 +96,7 @@ test.describe("Media metadata updates", () => { await admin.waitForLoading(); // Wait for the image to appear in the grid - const mediaGrid = page.locator(".grid.gap-4"); + const mediaGrid = page.locator("[data-media-grid]"); await expect(mediaGrid.locator("img").first()).toBeVisible({ timeout: 5000 }); // Click the image to open the detail panel diff --git a/e2e/tests/keyboard-shortcuts.spec.ts b/e2e/tests/keyboard-shortcuts.spec.ts index 60dac26db2..e36f2bb1a5 100644 --- a/e2e/tests/keyboard-shortcuts.spec.ts +++ b/e2e/tests/keyboard-shortcuts.spec.ts @@ -22,7 +22,7 @@ test.describe("Keyboard Shortcuts", () => { await admin.waitForLoading(); // Seed data includes uploaded media — click the first grid item (a button) - const mediaItem = page.locator(".grid.gap-4 button").first(); + const mediaItem = page.locator("[data-media-grid] button").first(); await expect(mediaItem).toBeVisible({ timeout: 10000 }); await mediaItem.click(); @@ -42,7 +42,7 @@ test.describe("Keyboard Shortcuts", () => { await admin.waitForLoading(); // Click the first media item - const mediaItem = page.locator(".grid.gap-4 button").first(); + const mediaItem = page.locator("[data-media-grid] button").first(); await expect(mediaItem).toBeVisible({ timeout: 10000 }); await mediaItem.click(); diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index a778e4583b..d9e169f37c 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -115,7 +115,7 @@ test.describe("Media Library", () => { await uploadTestImage(page); // Wait for the uploaded image to appear in the media grid - const mediaGrid = page.locator(".grid.gap-4"); + const mediaGrid = page.locator("[data-media-grid]"); await expect(mediaGrid.locator("img").first()).toBeVisible({ timeout: 5000 }); // Should have at least one image in the grid now diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index b196f25b0e..b01813188d 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -1,5 +1,4 @@ -import { Button, Input, Loader, Select, Tabs } from "@cloudflare/kumo"; -import { plural } from "@lingui/core/macro"; +import { Button, Input, Loader, Pagination, Select, Tabs } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Upload, Images, SquaresFour, List, MagnifyingGlass } from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; @@ -55,12 +54,25 @@ export interface MediaLibraryProps { hasMore?: boolean; /** Triggered to fetch the next page of local-library items */ onLoadMore?: () => void; + pagination?: MediaLibraryPagination; /** Called (debounced) with the filename search term for the local library. */ onLocalSearchChange?: (q: string) => void; /** Called with the MIME filter for the local library (undefined = all types). */ onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; } +export interface MediaLibraryPagination { + page: number; + perPage: number; + totalCount: number; + isPending: boolean; + onPageChange: (page: number) => void; + onPageSizeChange: (perPage: number) => void; +} + +const MEDIA_PAGE_SIZE_OPTIONS = [35, 70, 90]; +const MAX_DROPDOWN_PAGE_COUNT = 100; + /** * Media library component with upload, provider tabs, and grid view */ @@ -71,6 +83,7 @@ export function MediaLibrary({ onItemUpdated, hasMore, onLoadMore, + pagination, onLocalSearchChange, onLocalMimeFilterChange, }: MediaLibraryProps) { @@ -83,6 +96,11 @@ export function MediaLibrary({ const [localTypeFilter, setLocalTypeFilter] = React.useState("all"); const mediaHeadingRef = React.useRef(null); const detailOpenFrameRef = React.useRef(null); + const paginationRequestedRef = React.useRef(false); + const paginationWasPendingRef = React.useRef(false); + const paginationRootRef = React.useRef(null); + const paginationFocusTargetRef = React.useRef(null); + const paginationFocusFallbackRef = React.useRef<"page" | "page-size">("page"); // Debounced filename search reported up for the local library's server query. const debouncedSearch = useDebouncedValue(searchQuery, 300); React.useEffect(() => { @@ -149,6 +167,56 @@ export function MediaLibrary({ React.useEffect(() => cancelPendingDetailOpen, [cancelPendingDetailOpen]); + const requestPage = React.useCallback( + (nextPage: number) => { + if (!pagination || pagination.isPending) return; + const pageCount = Math.max(1, Math.ceil(pagination.totalCount / pagination.perPage)); + if (!Number.isSafeInteger(nextPage) || nextPage < 1 || nextPage > pageCount) return; + paginationRequestedRef.current = true; + paginationFocusTargetRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + paginationFocusFallbackRef.current = "page"; + pagination.onPageChange(nextPage); + }, + [pagination], + ); + const requestPageSize = React.useCallback( + (nextPerPage: number) => { + if (!pagination || pagination.isPending || !MEDIA_PAGE_SIZE_OPTIONS.includes(nextPerPage)) { + return; + } + paginationRequestedRef.current = true; + paginationFocusTargetRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + paginationFocusFallbackRef.current = "page-size"; + pagination.onPageSizeChange(nextPerPage); + }, + [pagination], + ); + React.useEffect(() => { + const pending = pagination?.isPending ?? false; + if (activeProvider !== "local") { + paginationRequestedRef.current = false; + paginationFocusTargetRef.current = null; + } else if (paginationRequestedRef.current && paginationWasPendingRef.current && !pending) { + paginationRequestedRef.current = false; + let focusTarget = paginationFocusTargetRef.current; + if (!focusTarget?.isConnected || focusTarget.matches(":disabled")) { + const slot = + paginationFocusFallbackRef.current === "page-size" + ? "pagination-page-size" + : "pagination-controls"; + focusTarget = + paginationRootRef.current?.querySelector( + `[data-slot="${slot}"] [role="combobox"], [data-slot="${slot}"] input, [data-slot="${slot}"] button:not(:disabled)`, + ) ?? null; + } + paginationFocusTargetRef.current = null; + focusTarget?.focus({ preventScroll: true }); + } + paginationWasPendingRef.current = pending; + }, [activeProvider, pagination?.isPending]); + const openDetail = React.useCallback( (item: MediaItem) => { cancelPendingDetailOpen(); @@ -256,13 +324,9 @@ export function MediaLibrary({ const currentLoading = activeProvider === "local" ? isLoading : providerLoading; const resultCount = - activeProvider === "local" ? currentItems.length : currentProviderItems.length; - const hasMoreCurrentItems = - activeProvider === "local" ? Boolean(hasMore) : Boolean(providerData?.nextCursor); - const resultCountText = - resultCount > 0 && !hasMoreCurrentItems - ? plural(resultCount, { one: "# item", other: "# items" }) - : ""; + activeProvider === "local" + ? (pagination?.totalCount ?? currentItems.length) + : currentProviderItems.length; const hasActiveQuery = searchQuery.trim() !== "" || (activeProvider === "local" && localTypeFilter !== "all"); const clearLocalQuery = () => { @@ -305,7 +369,7 @@ export function MediaLibrary({ }, [refetchProviderMedia, uploadTarget?.id]); return ( -
+
{isFileDragActive && (
)} - {/* Toolbar: search + type filter (start) · result count + view toggle (end). + {/* Toolbar: search + type filter (start) · view toggle (end). Local library search/filter is handled server-side. */} {showToolbar && (
@@ -404,10 +468,7 @@ export function MediaLibrary({ /> )}
-
- - {resultCountText} - +
) ) : viewMode === "grid" ? ( -
+
{activeProvider === "local" ? currentItems.map((item) => ( ) : ( -
+
@@ -592,8 +660,51 @@ export function MediaLibrary({ )} - {/* Load more (local library only — providers handle pagination internally) */} - {activeProvider === "local" && hasMore && onLoadMore && ( + {activeProvider === "local" && pagination && pagination.totalCount > 0 && ( +
+ + + {({ pageShowingRange, totalCount }) => ( + {t`Showing ${pageShowingRange} of ${totalCount ?? 0}`} + )} + + +
+ + +
+
+
+ )} + + {activeProvider === "local" && !pagination && hasMore && onLoadMore && (
+ + ); + } + + const screen = await render( + + + , + ); + + const nextPage = screen.getByRole("button", { name: "Next page" }); + await nextPage.click(); + await screen.getByRole("button", { name: "Finish page request" }).click(); + + await vi.waitFor(() => { + expect(document.activeElement).toBe(nextPage.element()); }); - await expect.element(screen.getByAltText("first-page.jpg")).toBeInTheDocument(); }); }); @@ -487,6 +587,7 @@ describe("MediaLibrary", () => { const screen = await renderLibrary({ items: [makeMediaItem({ id: "1", filename: "a.jpg" })], + pagination: makePagination(), }); await screen.getByRole("combobox", { name: "Filter by type" }).click(); @@ -494,6 +595,7 @@ describe("MediaLibrary", () => { await screen.getByRole("tab", { name: "Cloudflare Images" }).click(); await expect.element(screen.getByText("No media found")).toBeInTheDocument(); + 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(); }); diff --git a/packages/admin/tests/lib/media-pagination.test.ts b/packages/admin/tests/lib/media-pagination.test.ts new file mode 100644 index 0000000000..73ee3c2483 --- /dev/null +++ b/packages/admin/tests/lib/media-pagination.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchMediaList } from "../../src/lib/api/media"; + +describe("media page API client", () => { + const originalFetch = globalThis.fetch; + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ data: { items: [], totalCount: 37 } }), { status: 200 }), + ); + globalThis.fetch = fetchSpy as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("requests a numbered page and returns its exact total", async () => { + const result = await fetchMediaList({ page: 1, limit: 35 }); + const [url] = fetchSpy.mock.calls[0]!; + const requestUrl = new URL(url, "http://localhost"); + + expect(Object.fromEntries(requestUrl.searchParams)).toEqual({ page: "1", limit: "35" }); + expect(result).toEqual({ items: [], totalCount: 37 }); + }); +}); diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 17fe8ad336..73587a26be 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -110,7 +110,24 @@ vi.mock("../src/components/ContentEditor", () => ({ })); vi.mock("../src/components/MediaLibrary", () => ({ - MediaLibrary: ({ onUpload }: { onUpload?: (file: File) => Promise | void }) => { + MediaLibrary: ({ + items, + isLoading, + onUpload, + onLocalSearchChange, + pagination, + }: { + items?: Array<{ id?: string }>; + isLoading?: boolean; + onUpload?: (file: File) => Promise | void; + onLocalSearchChange?: (search: string) => void; + pagination?: { + page: number; + perPage: number; + onPageChange: (page: number) => void; + onPageSizeChange: (perPage: number) => void; + }; + }) => { const [uploadStatus, setUploadStatus] = React.useState("idle"); const upload = async () => { @@ -129,6 +146,24 @@ vi.mock("../src/components/MediaLibrary", () => ({ Upload test file {uploadStatus} + {items?.length ?? 0} + {items?.[0]?.id ?? ""} + {isLoading ? "loading" : "ready"} + {pagination && ( + <> + {pagination.page} + {pagination.perPage} + + + + + )}
); }, @@ -213,7 +248,7 @@ describe("MediaPage – upload completion", () => { data: { id: "user_01", role: 60 }, }) .on("GET", "/_emdash/api/media", { - data: { items: [], nextCursor: undefined }, + data: { items: [], totalCount: 60 }, }); }); @@ -258,6 +293,133 @@ describe("MediaPage – upload completion", () => { globalThis.fetch = interceptedFetch; } }); + + it("requests numbered pages and resets page state for page size and search", 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 expect.element(screen.getByTestId("media-page")).toHaveTextContent("1"); + await vi.waitFor(() => { + expect(requests.some((url) => url.includes("/_emdash/api/media?page=1&limit=35"))).toBe(true); + }); + + await screen.getByRole("button", { name: "Open page 2" }).click(); + await expect.element(screen.getByTestId("media-page")).toHaveTextContent("2"); + await vi.waitFor(() => { + expect(requests.some((url) => url.includes("/_emdash/api/media?page=2&limit=35"))).toBe(true); + }); + + await screen.getByRole("button", { name: "Show 70 per page" }).click(); + await expect.element(screen.getByTestId("media-page")).toHaveTextContent("1"); + await expect.element(screen.getByTestId("media-page-size")).toHaveTextContent("70"); + + await screen.getByRole("button", { name: "Search media" }).click(); + await vi.waitFor(() => { + expect( + requests.some( + (url) => url.includes("/_emdash/api/media?page=1&limit=70") && url.includes("q=photo"), + ), + ).toBe(true); + }); + }); + + it("recovers an emptied later page without exposing an invalid page number", async () => { + const mockedFetch = globalThis.fetch; + let requestedSecondPage = false; + 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") { + const requestedPage = url.searchParams.get("page"); + if (requestedPage === "2") { + requestedSecondPage = true; + return Promise.resolve( + new Response(JSON.stringify({ data: { items: [], totalCount: 0 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + const totalCount = requestedSecondPage ? 0 : 60; + return Promise.resolve( + new Response(JSON.stringify({ data: { items: [], totalCount } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await expect.element(screen.getByTestId("media-page")).toHaveTextContent("1"); + + await screen.getByRole("button", { name: "Open page 2" }).click(); + + await vi.waitFor(() => { + expect(requestedSecondPage).toBe(true); + expect(screen.getByTestId("media-page").element()).toHaveTextContent("1"); + }); + }); + + it("keeps the current page rendered while the next page loads", async () => { + const mockedFetch = globalThis.fetch; + let resolveSecondPage: ((response: Response) => void) | undefined; + 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") { + if (url.searchParams.get("page") === "2") { + return new Promise((resolve) => { + resolveSecondPage = resolve; + }); + } + return Promise.resolve( + new Response(JSON.stringify({ data: { items: [{ id: "page-1" }], totalCount: 60 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await expect.element(screen.getByTestId("media-item-count")).toHaveTextContent("1"); + await expect.element(screen.getByTestId("media-first-item")).toHaveTextContent("page-1"); + + await screen.getByRole("button", { name: "Open page 2" }).click(); + await vi.waitFor(() => expect(resolveSecondPage).toBeTypeOf("function")); + + await expect.element(screen.getByTestId("media-item-count")).toHaveTextContent("1"); + await expect.element(screen.getByTestId("media-first-item")).toHaveTextContent("page-1"); + await expect.element(screen.getByTestId("media-loading")).toHaveTextContent("loading"); + + resolveSecondPage?.( + new Response(JSON.stringify({ data: { items: [{ id: "page-2" }], totalCount: 60 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + await expect.element(screen.getByTestId("media-loading")).toHaveTextContent("ready"); + await expect.element(screen.getByTestId("media-first-item")).toHaveTextContent("page-2"); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/api/handlers/media.ts b/packages/core/src/api/handlers/media.ts index 1ad8b8e7c6..39cb809988 100644 --- a/packages/core/src/api/handlers/media.ts +++ b/packages/core/src/api/handlers/media.ts @@ -12,6 +12,7 @@ import type { ApiResult } from "../types.js"; export interface MediaListResponse { items: MediaItem[]; nextCursor?: string; + totalCount?: number; } export interface MediaResponse { @@ -25,12 +26,38 @@ export async function handleMediaList( db: Kysely, params: { cursor?: string; + page?: number; limit?: number; mimeType?: string | readonly string[]; q?: string; }, ): Promise> { try { + if (params.page !== undefined) { + const limit = Math.min(params.limit || 50, 100); + const offset = (params.page - 1) * limit; + if ( + params.cursor !== undefined || + !Number.isSafeInteger(params.page) || + params.page < 1 || + !Number.isSafeInteger(offset) + ) { + return { + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid media page" }, + }; + } + + const repo = new MediaRepository(db); + const result = await repo.findPage({ + page: params.page, + limit, + mimeType: params.mimeType, + q: params.q, + }); + return { success: true, data: result }; + } + const repo = new MediaRepository(db); const result = await repo.findMany({ cursor: params.cursor, diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 5a5f335dc1..749bb1c2a0 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -21,6 +21,7 @@ const mimeTypeFilter = z export const mediaListQuery = cursorPaginationQuery .extend({ + page: z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(), mimeType: mimeTypeFilter, /** Case-insensitive filename substring search (also matches extensions). */ q: z.string().trim().min(1).max(200).optional(), @@ -28,6 +29,10 @@ export const mediaListQuery = cursorPaginationQuery description: "Include a coverage-aware usage summary on each media item", }), }) + .refine(({ cursor, page }) => cursor === undefined || page === undefined, { + message: "cursor and page cannot be used together", + path: ["page"], + }) .meta({ id: "MediaListQuery" }); export const mediaGetQuery = z @@ -143,6 +148,7 @@ export const mediaListReadResponseSchema = z .object({ items: z.array(mediaListReadItemSchema), nextCursor: z.string().optional(), + totalCount: z.number().int().nonnegative().optional(), }) .meta({ id: "MediaListReadResponse" }); @@ -150,6 +156,7 @@ export const mediaListResponseSchema = z .object({ items: z.array(mediaItemSchema), nextCursor: z.string().optional(), + totalCount: z.number().int().nonnegative().optional(), }) .meta({ id: "MediaListResponse" }); diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 9dadf3f588..205f08644c 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -55,6 +55,7 @@ export const GET: APIRoute = async ({ request, locals }) => { const result = await emdash.handleMediaList({ cursor: query.cursor, + page: query.page, limit: query.limit, mimeType: query.mimeType, q: query.q, @@ -67,7 +68,11 @@ export const GET: APIRoute = async ({ request, locals }) => { // Add URL to each media item (relative URLs for portability) const itemsWithUrl = result.data.items.map((item) => addUrlToMedia(item)); if (query.includeUsage !== "1") { - return apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor }); + return apiSuccess({ + items: itemsWithUrl, + nextCursor: result.data.nextCursor, + totalCount: result.data.totalCount, + }); } const includeCount = canReadMediaUsageCount(user, locals.tokenScopes); @@ -85,7 +90,11 @@ export const GET: APIRoute = async ({ request, locals }) => { itemsWithUsage.push({ ...item, usage }); } - return apiSuccess({ items: itemsWithUsage, nextCursor: result.data.nextCursor }); + return apiSuccess({ + items: itemsWithUsage, + nextCursor: result.data.nextCursor, + totalCount: result.data.totalCount, + }); }; /** diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 1fb48878dd..3d7e136879 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -374,6 +374,7 @@ export interface EmDashHandlers { // Media handlers handleMediaList: (params: { cursor?: string; + page?: number; limit?: number; mimeType?: string | readonly string[]; }) => Promise; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 0221037051..dbcc918f01 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -811,16 +811,21 @@ export class EmDashClient { mimeType?: string; limit?: number; cursor?: string; + page?: number; includeUsage?: boolean; - }): Promise> { + }): Promise & { totalCount?: number }> { const params = new URLSearchParams(); if (options?.mimeType) params.set("mimeType", options.mimeType); if (options?.limit) params.set("limit", String(options.limit)); if (options?.cursor) params.set("cursor", options.cursor); + if (options?.page !== undefined) params.set("page", String(options.page)); if (options?.includeUsage === true) params.set("includeUsage", "1"); const qs = params.toString(); - return this.request>("GET", `/media${qs ? `?${qs}` : ""}`); + return this.request & { totalCount?: number }>( + "GET", + `/media${qs ? `?${qs}` : ""}`, + ); } /** Get a single media item */ diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 5d6335ff1d..ca47d3a8c7 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -1,4 +1,10 @@ -import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely"; +import { + sql, + type ExpressionBuilder, + type Kysely, + type SelectQueryBuilder, + type SqlBool, +} from "kysely"; import { ulid } from "ulidx"; import type { Database, MediaRow } from "../types.js"; @@ -85,6 +91,15 @@ export interface FindManyMediaOptions { q?: string; } +export interface FindMediaPageOptions extends Omit { + page: number; +} + +export interface MediaPageResult { + items: MediaItem[]; + totalCount: number; +} + const UPLOAD_ATTEMPT_CLEANUP_AGE_MS = 60 * 60 * 1000; const UPLOAD_ATTEMPT_CLEANUP_BATCH_SIZE = 100; @@ -367,8 +382,7 @@ export class MediaRepository { async findMany(options: FindManyMediaOptions = {}): Promise> { const limit = Math.min(options.limit || 50, 100); - let query = this.db - .selectFrom("media") + let query = this.applyListFilters(this.db.selectFrom("media"), options) .selectAll() .orderBy("created_at", "desc") .orderBy("id", "desc") @@ -387,28 +401,6 @@ export class MediaRepository { ); } - const mimeFilters = normalizeMimeFilter(options.mimeType); - if (mimeFilters.length > 0) { - query = query.where((eb) => mimeMatchExpr(eb, mimeFilters)); - } - - // Case-insensitive filename substring search (also matches extensions). - // LIKE wildcards in the term are escaped so they're treated literally. - const term = options.q?.trim(); - if (term) { - const pattern = `%${escapeLike(term)}%`; - query = query.where( - sql`lower(filename)`, - "like", - sql`lower(${pattern}) escape '\\'`, - ); - } - - // Default to only showing ready items - if (options.status !== "all") { - query = query.where("status", "=", options.status ?? "ready"); - } - const rows = await query.execute(); const hasMore = rows.length > limit; @@ -423,6 +415,27 @@ export class MediaRepository { return { items, nextCursor }; } + async findPage(options: FindMediaPageOptions): Promise { + const limit = Math.min(options.limit || 50, 100); + const offset = (options.page - 1) * limit; + const filtered = this.applyListFilters(this.db.selectFrom("media"), options); + const rows = await filtered + .selectAll() + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .limit(limit) + .offset(offset) + .execute(); + const count = await filtered + .select((eb) => eb.fn.count("id").as("count")) + .executeTakeFirst(); + + return { + items: rows.map((row) => this.rowToItem(row)), + totalCount: Number(count?.count ?? 0), + }; + } + /** * Update media metadata */ @@ -480,6 +493,32 @@ export class MediaRepository { return Number(result?.count || 0); } + private applyListFilters( + query: SelectQueryBuilder, + options: Omit, + ): SelectQueryBuilder { + const mimeFilters = normalizeMimeFilter(options.mimeType); + if (mimeFilters.length > 0) { + query = query.where((eb) => mimeMatchExpr(eb, mimeFilters)); + } + + const term = options.q?.trim(); + if (term) { + const pattern = `%${escapeLike(term)}%`; + query = query.where( + sql`lower(filename)`, + "like", + sql`lower(${pattern}) escape '\\'`, + ); + } + + if (options.status !== "all") { + query = query.where("status", "=", options.status ?? "ready"); + } + + return query; + } + /** * Delete pending uploads older than the given age. * Pending uploads that were never confirmed indicate abandoned upload flows. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 8490cc7004..d2697a437e 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3456,6 +3456,7 @@ export class EmDashRuntime { async handleMediaList(params: { cursor?: string; + page?: number; limit?: number; mimeType?: string | readonly string[]; q?: string; diff --git a/packages/core/tests/integration/database/media-page-pagination.test.ts b/packages/core/tests/integration/database/media-page-pagination.test.ts new file mode 100644 index 0000000000..c598eb0aac --- /dev/null +++ b/packages/core/tests/integration/database/media-page-pagination.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { MediaRepository, type MediaStatus } from "../../../src/database/repositories/media.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("MediaRepository numbered pages", (dialect) => { + let ctx: DialectTestContext; + let repo: MediaRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + repo = new MediaRepository(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function createMedia( + filename: string, + createdAt: string, + mimeType = "image/jpeg", + status: MediaStatus = "ready", + ) { + const item = await repo.create({ + filename, + mimeType, + storageKey: filename, + status, + }); + await ctx.db + .updateTable("media") + .set({ created_at: createdAt }) + .where("id", "=", item.id) + .execute(); + } + + async function seedMedia() { + await createMedia("oldest.jpg", "2026-01-01T00:00:00.000Z"); + await createMedia("guide.pdf", "2026-01-02T00:00:00.000Z", "application/pdf"); + await createMedia("middle.jpg", "2026-01-03T00:00:00.000Z"); + await createMedia("newest.jpg", "2026-01-04T00:00:00.000Z"); + await createMedia("pending.jpg", "2026-01-05T00:00:00.000Z", "image/jpeg", "pending"); + await createMedia("failed.jpg", "2026-01-06T00:00:00.000Z", "image/jpeg", "failed"); + } + + it("returns disjoint stable pages with the exact ready-item total", async () => { + await seedMedia(); + + const first = await repo.findPage({ page: 1, limit: 2 }); + const second = await repo.findPage({ page: 2, limit: 2 }); + const beyond = await repo.findPage({ page: 3, limit: 2 }); + + expect(first).toMatchObject({ totalCount: 4 }); + expect(first.items.map((item) => item.filename)).toEqual(["newest.jpg", "middle.jpg"]); + expect(second).toMatchObject({ totalCount: 4 }); + expect(second.items.map((item) => item.filename)).toEqual(["guide.pdf", "oldest.jpg"]); + expect(beyond).toEqual({ items: [], totalCount: 4 }); + }); + + it("applies filename and MIME filters to both rows and total", async () => { + await seedMedia(); + + const result = await repo.findPage({ + page: 2, + limit: 2, + q: ".jpg", + mimeType: "image/", + }); + + expect(result.totalCount).toBe(3); + expect(typeof result.totalCount).toBe("number"); + expect(result.items.map((item) => item.filename)).toEqual(["oldest.jpg"]); + }); +}); diff --git a/packages/core/tests/unit/api/media-list-route.test.ts b/packages/core/tests/unit/api/media-list-route.test.ts index 90b63f0da6..b27e0268dc 100644 --- a/packages/core/tests/unit/api/media-list-route.test.ts +++ b/packages/core/tests/unit/api/media-list-route.test.ts @@ -1,6 +1,7 @@ import { it, expect, describe, beforeEach, afterEach } from "vitest"; import { handleMediaList } from "../../../src/api/handlers/media.js"; +import { mediaListQuery } from "../../../src/api/schemas/media.js"; import { MediaRepository } from "../../../src/database/repositories/media.js"; import { setupForDialect, @@ -33,4 +34,43 @@ describe("handleMediaList multi-MIME", () => { "image/png", ]); }); + + it("returns one numbered page and its exact total", async () => { + const result = await handleMediaList(ctx.db, { page: 2, limit: 2 }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + items: [expect.objectContaining({ filename: expect.stringMatching(/\.(png|pdf|zip)$/) })], + totalCount: 3, + }), + }), + ); + }); + + it("rejects invalid or ambiguous page requests before querying", async () => { + await expect(handleMediaList(ctx.db, { page: 0 })).resolves.toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + await expect(handleMediaList(ctx.db, { page: 1, cursor: "cursor" })).resolves.toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + await expect( + handleMediaList(ctx.db, { page: Number.MAX_SAFE_INTEGER, limit: 100 }), + ).resolves.toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + }); + + it("accepts page mode in the REST query and rejects cursor plus page", () => { + expect(mediaListQuery.parse({ page: "1" }).page).toBe(1); + expect(mediaListQuery.safeParse({ page: "0" }).success).toBe(false); + expect(mediaListQuery.safeParse({ page: "1.5" }).success).toBe(false); + expect(mediaListQuery.safeParse({ page: "not-a-page" }).success).toBe(false); + expect(mediaListQuery.safeParse({ page: "1", cursor: "cursor" }).success).toBe(false); + }); }); diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts index b14b339fc0..de6f3f2b2e 100644 --- a/packages/core/tests/unit/api/media-usage-summary.test.ts +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -313,6 +313,41 @@ describe("media usage summary handler and routes", () => { expect(queries).toHaveLength(1); }); + it("returns an exact total only for page-mode list requests", async () => { + const response = await invokeList("?page=1&limit=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ + items: MediaListBodyItem[]; + totalCount?: number; + }>(response); + + expect(response.status).toBe(200); + expect(data.items).toHaveLength(1); + expect(data.totalCount).toBe(2); + expect(queries).toHaveLength(2); + }); + + it("preserves the page total when usage summaries are requested", async () => { + const response = await invokeList("?page=1&limit=1&includeUsage=1", Role.CONTRIBUTOR); + const data = await readSuccess<{ + items: MediaListBodyItem[]; + totalCount?: number; + }>(response); + + expect(data.totalCount).toBe(2); + expect(data.items[0]?.usage).toBeDefined(); + expect(queries).toHaveLength(4); + }); + + it("rejects cursor plus page without running a media query", async () => { + const response = await invokeList("?page=1&cursor=cursor", Role.CONTRIBUTOR); + + expect(response.status).toBe(400); + expect((await response.json()) as ErrorBody).toEqual( + expect.objectContaining({ error: expect.objectContaining({ code: "VALIDATION_ERROR" }) }), + ); + expect(queries).toHaveLength(0); + }); + it("attaches numeric usage counts to every list item for an authorized session", async () => { const response = await invokeList("?includeUsage=1", Role.CONTRIBUTOR); const data = await readSuccess<{ items: MediaListBodyItem[] }>(response); diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index 8d85286df3..31f605e658 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -724,6 +724,27 @@ describe("EmDashClient", () => { }); describe("media usage reads", () => { + it("requests a numbered media page and returns its total", async () => { + let capturedUrl: URL | undefined; + const backend: Interceptor = async (req) => { + capturedUrl = new URL(req.url); + return jsonResponse({ items: [{ id: "media-1" }], totalCount: 37 }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const result = await client.mediaList({ page: 2, limit: 25 }); + + expect(Object.fromEntries(capturedUrl?.searchParams ?? [])).toEqual({ + limit: "25", + page: "2", + }); + expect(result).toEqual({ items: [{ id: "media-1" }], totalCount: 37 }); + }); + it("opts media list into usage summaries with an exact includeUsage value", async () => { let capturedUrl: URL | undefined; const backend: Interceptor = async (req) => {