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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/bright-media-pages.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion e2e/tests/admin-fixes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/keyboard-shortcuts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/media-library.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 130 additions & 19 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
*/
Expand All @@ -71,6 +83,7 @@ export function MediaLibrary({
onItemUpdated,
hasMore,
onLoadMore,
pagination,
onLocalSearchChange,
onLocalMimeFilterChange,
}: MediaLibraryProps) {
Expand All @@ -83,6 +96,11 @@ export function MediaLibrary({
const [localTypeFilter, setLocalTypeFilter] = React.useState("all");
const mediaHeadingRef = React.useRef<HTMLHeadingElement>(null);
const detailOpenFrameRef = React.useRef<number | null>(null);
const paginationRequestedRef = React.useRef(false);
const paginationWasPendingRef = React.useRef(false);
const paginationRootRef = React.useRef<HTMLDivElement>(null);
const paginationFocusTargetRef = React.useRef<HTMLElement | null>(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(() => {
Expand Down Expand Up @@ -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<HTMLElement>(
`[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();
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -305,7 +369,7 @@ export function MediaLibrary({
}, [refetchProviderMedia, uploadTarget?.id]);

return (
<div className="space-y-4" data-media-library>
<div className="space-y-4" data-media-library aria-busy={currentLoading || undefined}>
{isFileDragActive && (
<div
className="pointer-events-none fixed inset-0 z-50 bg-kumo-base/70 p-4 backdrop-blur-sm sm:p-8"
Expand Down Expand Up @@ -363,7 +427,7 @@ export function MediaLibrary({
/>
)}

{/* 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 && (
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
Expand Down Expand Up @@ -404,10 +468,7 @@ export function MediaLibrary({
/>
)}
</div>
<div className="flex flex-shrink-0 items-center justify-between gap-3 sm:justify-end">
<span className="text-sm text-kumo-subtle tabular-nums" aria-live="polite">
{resultCountText}
</span>
<div className="flex flex-shrink-0 items-center justify-end">
<div role="group" aria-label={t`View mode`}>
<Tabs
variant="segmented"
Expand Down Expand Up @@ -505,7 +566,11 @@ export function MediaLibrary({
/>
)
) : viewMode === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
<div
data-media-grid
inert={currentLoading || undefined}
className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]"
>
{activeProvider === "local"
? currentItems.map((item) => (
<MediaGridItem
Expand Down Expand Up @@ -542,7 +607,10 @@ export function MediaLibrary({
))}
</div>
) : (
<div className="rounded-md border bg-kumo-base overflow-x-auto">
<div
inert={currentLoading || undefined}
className="rounded-md border bg-kumo-base overflow-x-auto"
>
<table className="w-full">
<thead>
<tr className="border-b bg-kumo-tint/50">
Expand Down Expand Up @@ -592,8 +660,51 @@ export function MediaLibrary({
</div>
)}

{/* Load more (local library only — providers handle pagination internally) */}
{activeProvider === "local" && hasMore && onLoadMore && (
{activeProvider === "local" && pagination && pagination.totalCount > 0 && (
<div ref={paginationRootRef} className="min-w-0">
<Pagination
page={pagination.page}
setPage={requestPage}
perPage={pagination.perPage}
totalCount={pagination.totalCount}
className="flex-wrap gap-y-3"
labels={{
navigation: t`Media pagination`,
firstPage: t`First page`,
previousPage: t`Previous page`,
nextPage: t`Next page`,
lastPage: t`Last page`,
pageNumber: t`Page number`,
pageSize: t`Page size`,
}}
>
<Pagination.Info className="min-w-fit">
{({ pageShowingRange, totalCount }) => (
<span role="status">{t`Showing ${pageShowingRange} of ${totalCount ?? 0}`}</span>
)}
</Pagination.Info>
<Pagination.Separator className="hidden sm:block" />
<div inert={pagination.isPending || undefined} className="contents">
<Pagination.PageSize
value={pagination.perPage}
onChange={requestPageSize}
options={MEDIA_PAGE_SIZE_OPTIONS}
label={t`Per page`}
/>
<Pagination.Controls
pageSelector={
Math.ceil(pagination.totalCount / pagination.perPage) <= MAX_DROPDOWN_PAGE_COUNT
? "dropdown"
: "input"
}
className="basis-full sm:basis-auto rtl:[&_svg]:-scale-x-100"
/>
</div>
</Pagination>
</div>
)}

{activeProvider === "local" && !pagination && hasMore && onLoadMore && (
<div className="flex justify-center">
<Button variant="outline" onClick={onLoadMore} disabled={isLoading}>
{isLoading ? t`Loading...` : t`Load More`}
Expand Down
10 changes: 8 additions & 2 deletions packages/admin/src/lib/api/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,24 @@ export interface MediaItem {
meta?: Record<string, unknown>;
}

export interface MediaListResult extends FindManyResult<MediaItem> {
totalCount?: number;
}

/**
* Fetch media list
*/
export async function fetchMediaList(options?: {
cursor?: string;
page?: number;
limit?: number;
mimeType?: string | string[];
/** Case-insensitive filename substring search (also matches extensions). */
search?: string;
}): Promise<FindManyResult<MediaItem>> {
}): Promise<MediaListResult> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.page !== undefined) params.set("page", String(options.page));
if (options?.limit) params.set("limit", String(options.limit));
if (options?.mimeType) {
const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType;
Expand All @@ -77,7 +83,7 @@ export async function fetchMediaList(options?: {

const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<MediaItem>>(response, i18n._(msg`Failed to fetch media`));
return parseApiResponse<MediaListResult>(response, i18n._(msg`Failed to fetch media`));
}

/**
Expand Down
Loading
Loading