Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/fix-media-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Add search and filtering to the media library (#1221). The media list endpoint now accepts a `q` parameter for a case-insensitive filename substring search (which also matches extensions, with LIKE wildcards escaped), alongside the existing `mimeType` filter. The Media Library page gains a filename search box and a type filter (images / video / audio / documents), and the media picker in the content editor now searches the local library by filename too. Previously neither surface could search or filter local media, which made large libraries hard to navigate.
77 changes: 65 additions & 12 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Input, Loader } from "@cloudflare/kumo";
import { Button, Input, Loader, Select } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react";
Expand All @@ -13,10 +13,27 @@ import {
fetchProviderMedia,
uploadToProvider,
} from "../lib/api";
import { useDebouncedValue } from "../lib/hooks.js";
import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils";
import { cn } from "../lib/utils";
import { MediaDetailPanel } from "./MediaDetailPanel";

/** Maps a coarse type-filter choice to the media list's `mimeType` filter. */
function mimeForTypeFilter(value: string): string | string[] | undefined {
switch (value) {
case "image":
return "image/";
case "video":
return "video/";
case "audio":
return "audio/";
case "document":
return ["application/", "text/"];
default:
return undefined;
}
}

export interface MediaLibraryProps {
items?: MediaItem[];
isLoading?: boolean;
Expand All @@ -28,6 +45,10 @@ export interface MediaLibraryProps {
hasMore?: boolean;
/** Triggered to fetch the next page of local-library items */
onLoadMore?: () => void;
/** 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;
}

/**
Expand All @@ -41,12 +62,22 @@ export function MediaLibrary({
onItemUpdated,
hasMore,
onLoadMore,
onLocalSearchChange,
onLocalMimeFilterChange,
}: MediaLibraryProps) {
const { t } = useLingui();
const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = React.useState<MediaItem | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string>("local");
const [searchQuery, setSearchQuery] = React.useState("");
const [localTypeFilter, setLocalTypeFilter] = React.useState("all");
// Debounced filename search reported up for the local library's server query.
const debouncedSearch = useDebouncedValue(searchQuery, 300);
React.useEffect(() => {
if (activeProvider === "local" && onLocalSearchChange) {
onLocalSearchChange(debouncedSearch.trim());
}
}, [debouncedSearch, activeProvider, onLocalSearchChange]);
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "success" | "error";
message?: string;
Expand Down Expand Up @@ -333,17 +364,39 @@ export function MediaLibrary({
</div>
</div>

{/* Search (for providers that support it) */}
{canSearch && (
<div className="relative max-w-sm">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9"
/>
{/* Search — providers that support it, plus the local library
(filename/extension search + type filter, handled server-side). */}
{(canSearch || activeProvider === "local") && (
<div className="flex flex-wrap items-center gap-3">
<div className="relative max-w-sm flex-1">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={activeProvider === "local" ? t`Search by filename...` : t`Search...`}
aria-label={t`Search media`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ps-9"
/>
</div>
{activeProvider === "local" && (
<Select
value={localTypeFilter}
onValueChange={(v) => {
const next = v ?? "all";
setLocalTypeFilter(next);
onLocalMimeFilterChange?.(mimeForTypeFilter(next));
}}
items={{
all: t`All types`,
image: t`Images`,
video: t`Video`,
audio: t`Audio`,
document: t`Documents`,
}}
aria-label={t`Filter by type`}
/>
)}
</div>
)}

Expand Down
13 changes: 9 additions & 4 deletions packages/admin/src/components/MediaPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type MediaProviderInfo,
type MediaProviderItem,
} from "../lib/api";
import { useDebouncedValue } from "../lib/hooks.js";
import { providerItemToMediaItem, getFileIcon } from "../lib/media-utils";
import { matchesMimeAllowlist, mimeFromUrl } from "../lib/mime-utils.js";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -145,6 +146,8 @@ export function MediaPickerModal({
const [selectedItem, setSelectedItem] = React.useState<SelectedMedia | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string>("local");
const [searchQuery, setSearchQuery] = React.useState("");
// Debounced for the local library's server-side filename search.
const debouncedSearch = useDebouncedValue(searchQuery, 300);
const fileInputRef = React.useRef<HTMLInputElement>(null);

// URL input state
Expand Down Expand Up @@ -208,12 +211,13 @@ export function MediaPickerModal({
hasNextPage: hasNextLocalPage,
isFetchingNextPage: isFetchingNextLocalPage,
} = useInfiniteQuery({
queryKey: ["media", filters?.join(",") ?? ""],
queryKey: ["media", filters?.join(",") ?? "", debouncedSearch.trim()],
queryFn: ({ pageParam }) =>
fetchMediaList({
mimeType: filters,
cursor: pageParam,
limit: 100,
search: debouncedSearch.trim() || undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -555,13 +559,14 @@ export function MediaPickerModal({

{/* Toolbar */}
<div className="flex items-center justify-between pb-3 gap-4">
{/* Search (if provider supports it) */}
{canSearch ? (
{/* Search — providers that support it, plus the local library
(filename/extension search, handled server-side). */}
{canSearch || activeProvider === "local" ? (
<div className="relative flex-1 max-w-xs">
<MagnifyingGlass className="absolute start-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
<Input
type="search"
placeholder={t`Search...`}
placeholder={activeProvider === "local" ? t`Search by filename...` : t`Search...`}
aria-label={t`Search media`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
Expand Down
3 changes: 3 additions & 0 deletions packages/admin/src/lib/api/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export async function fetchMediaList(options?: {
cursor?: string;
limit?: number;
mimeType?: string | string[];
/** Case-insensitive filename substring search (also matches extensions). */
search?: string;
}): Promise<FindManyResult<MediaItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
Expand All @@ -47,6 +49,7 @@ export async function fetchMediaList(options?: {
const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType;
if (value) params.set("mimeType", value);
}
if (options?.search) params.set("q", options.search);

const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
Expand Down
11 changes: 10 additions & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1003,13 +1003,20 @@ const mediaRoute = createRoute({
function MediaPage() {
const queryClient = useQueryClient();

// Filename search + MIME type filter for the local library (server-side).
const [search, setSearch] = React.useState("");
const [mimeFilter, setMimeFilter] = React.useState<string | string[] | undefined>(undefined);
const mimeKey = Array.isArray(mimeFilter) ? mimeFilter.join(",") : (mimeFilter ?? "");

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
useInfiniteQuery({
queryKey: ["media"],
queryKey: ["media", { search, mime: mimeKey }],
queryFn: ({ pageParam }) =>
fetchMediaList({
cursor: pageParam,
limit: 100,
search: search || undefined,
mimeType: mimeFilter,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -1045,6 +1052,8 @@ function MediaPage() {
onLoadMore={() => void fetchNextPage()}
onUpload={(file) => uploadMutation.mutate(file)}
onDelete={(id) => deleteMutation.mutate(id)}
onLocalSearchChange={setSearch}
onLocalMimeFilterChange={setMimeFilter}
/>
);
}
Expand Down
27 changes: 27 additions & 0 deletions packages/admin/tests/components/MediaLibrary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
await expect.element(screen.getByText("test.jpg")).toBeInTheDocument();
// Table headers should be visible
await expect.element(screen.getByText("Filename")).toBeInTheDocument();
await expect.element(screen.getByText("Type")).toBeInTheDocument();

Check failure on line 108 in packages/admin/tests/components/MediaLibrary.test.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/MediaLibrary.test.tsx > MediaLibrary > view mode toggle > switches between grid and list view

Error: strict mode violation: page.getByText('Type') resolved to 2 elements: 1) <span class="min-w-0 truncate data-[placeholder]:text-kumo-placeholder">All types</span> aka getByText('All types') 2) <th class="px-4 py-3 text-start text-sm font-medium">Type</th> aka getByRole('cell', { name: 'Type' }) ❯ toBeInTheDocument tests/components/MediaLibrary.test.tsx:108:50 Caused by: Caused by: Error: Matcher did not succeed in time. ❯ tests/components/MediaLibrary.test.tsx:108:3
await expect.element(screen.getByText("Size")).toBeInTheDocument();
});
});
Expand Down Expand Up @@ -218,4 +218,31 @@
await expect.element(screen.getByAltText("first-page.jpg")).toBeInTheDocument();
});
});

// #1221: the local library gained filename search + a type filter.
describe("local search and filter", () => {
it("reports the debounced filename query upward", async () => {
const onLocalSearchChange = vi.fn();
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
const screen = await renderLibrary({ items, onLocalSearchChange });

await screen.getByRole("searchbox", { name: "Search media" }).fill("vacation");

await vi.waitFor(() => {
expect(onLocalSearchChange).toHaveBeenCalledWith("vacation");
});
});

it("reports a MIME filter when a type is chosen", async () => {
const onLocalMimeFilterChange = vi.fn();
const items = [makeMediaItem({ id: "1", filename: "a.jpg" })];
const screen = await renderLibrary({ items, onLocalMimeFilterChange });

// Open the type filter and choose Images.
await screen.getByRole("combobox", { name: "Filter by type" }).click();
await screen.getByRole("option", { name: "Images" }).click();

expect(onLocalMimeFilterChange).toHaveBeenCalledWith("image/");
});
});
});
2 changes: 2 additions & 0 deletions packages/core/src/api/handlers/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function handleMediaList(
cursor?: string;
limit?: number;
mimeType?: string | readonly string[];
q?: string;
},
): Promise<ApiResult<MediaListResponse>> {
try {
Expand All @@ -35,6 +36,7 @@ export async function handleMediaList(
cursor: params.cursor,
limit: Math.min(params.limit || 50, 100),
mimeType: params.mimeType,
q: params.q,
});

return {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/schemas/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const mimeTypeFilter = z
export const mediaListQuery = cursorPaginationQuery
.extend({
mimeType: mimeTypeFilter,
/** Case-insensitive filename substring search (also matches extensions). */
q: z.string().trim().min(1).max(200).optional(),
})
.meta({ id: "MediaListQuery" });

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/routes/api/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
cursor: query.cursor,
limit: query.limit,
mimeType: query.mimeType,
q: query.q,
});

if (!result.success) {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/database/repositories/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ export interface FindManyMediaOptions {
/** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */
mimeType?: string | readonly string[];
status?: MediaStatus | "all"; // Filter by status, defaults to "ready"
/** Case-insensitive substring matched against the filename (covers filename and extension). */
q?: string;
}

// LIKE wildcards that must be escaped so user search input is matched literally.
const LIKE_WILDCARD_RE = /[\\%_]/g;

/**
* Media repository for database operations
*/
Expand Down Expand Up @@ -250,6 +255,18 @@ export class MediaRepository {
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 = `%${term.replace(LIKE_WILDCARD_RE, (c) => `\\${c}`)}%`;
query = query.where(
sql<string>`lower(filename)`,
"like",
sql<string>`lower(${pattern}) escape '\\'`,
);
}

// Default to only showing ready items
if (options.status !== "all") {
query = query.where("status", "=", options.status ?? "ready");
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,7 @@ export class EmDashRuntime {
cursor?: string;
limit?: number;
mimeType?: string | readonly string[];
q?: string;
}) {
return handleMediaList(this.db, params);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { it, expect, beforeEach, afterEach } from "vitest";

import { MediaRepository } from "../../../src/database/repositories/media.js";
import {
describeEachDialect,
setupForDialect,
teardownForDialect,
type DialectTestContext,
} from "../../utils/test-db.js";

// #1221: the media library lacked filename search. The repository now accepts
// a case-insensitive `q` substring filter against the filename.
describeEachDialect("MediaRepository.findMany filename search (#1221)", (dialect) => {
let ctx: DialectTestContext;

beforeEach(async () => {
ctx = await setupForDialect(dialect);
const repo = new MediaRepository(ctx.db);
await repo.create({
filename: "Summer-Vacation.png",
mimeType: "image/png",
storageKey: "1.png",
});
await repo.create({
filename: "invoice-2024.pdf",
mimeType: "application/pdf",
storageKey: "2.pdf",
});
await repo.create({ filename: "logo.svg", mimeType: "image/svg+xml", storageKey: "3.svg" });
await repo.create({
filename: "100%_complete.png",
mimeType: "image/png",
storageKey: "4.png",
});
});

afterEach(async () => {
await teardownForDialect(ctx);
});

it("matches a filename substring case-insensitively", async () => {
const repo = new MediaRepository(ctx.db);
const result = await repo.findMany({ q: "vacation" });
expect(result.items.map((i) => i.filename)).toEqual(["Summer-Vacation.png"]);
});

it("matches by extension", async () => {
const repo = new MediaRepository(ctx.db);
const result = await repo.findMany({ q: ".pdf" });
expect(result.items.map((i) => i.filename)).toEqual(["invoice-2024.pdf"]);
});

it("combines with the mimeType filter", async () => {
const repo = new MediaRepository(ctx.db);
const result = await repo.findMany({ q: "logo", mimeType: "image/" });
expect(result.items.map((i) => i.filename)).toEqual(["logo.svg"]);
});

it("treats LIKE wildcards in the query literally", async () => {
const repo = new MediaRepository(ctx.db);
// "100%" must match only the literal "100%_complete.png", not every row.
const result = await repo.findMany({ q: "100%" });
expect(result.items.map((i) => i.filename)).toEqual(["100%_complete.png"]);
});
});
Loading