diff --git a/.changeset/fix-media-search.md b/.changeset/fix-media-search.md new file mode 100644 index 000000000..b363e7232 --- /dev/null +++ b/.changeset/fix-media-search.md @@ -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. diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index 529b7d339..5a7a5a225 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -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"; @@ -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; @@ -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; } /** @@ -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(null); const [activeProvider, setActiveProvider] = React.useState("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; @@ -333,17 +364,39 @@ export function MediaLibrary({ - {/* Search (for providers that support it) */} - {canSearch && ( -
- - 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") && ( +
+
+ + setSearchQuery(e.target.value)} + className="ps-9" + /> +
+ {activeProvider === "local" && ( + setSearchQuery(e.target.value)} diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 94b11f79c..febe7ce1d 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -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> { const params = new URLSearchParams(); if (options?.cursor) params.set("cursor", options.cursor); @@ -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); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 19176321e..59dbf7bd0 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -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(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, @@ -1045,6 +1052,8 @@ function MediaPage() { onLoadMore={() => void fetchNextPage()} onUpload={(file) => uploadMutation.mutate(file)} onDelete={(id) => deleteMutation.mutate(id)} + onLocalSearchChange={setSearch} + onLocalMimeFilterChange={setMimeFilter} /> ); } diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index d23f9493f..cc5ff17b1 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -218,4 +218,31 @@ describe("MediaLibrary", () => { 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/"); + }); + }); }); diff --git a/packages/core/src/api/handlers/media.ts b/packages/core/src/api/handlers/media.ts index 89e6c6fa6..2859a2410 100644 --- a/packages/core/src/api/handlers/media.ts +++ b/packages/core/src/api/handlers/media.ts @@ -27,6 +27,7 @@ export async function handleMediaList( cursor?: string; limit?: number; mimeType?: string | readonly string[]; + q?: string; }, ): Promise> { try { @@ -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 { diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 9b0554536..3b2519e10 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -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" }); diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 4ed579e4e..9fa764443 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -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) { diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index a94b27274..d589b7032 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -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 */ @@ -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`lower(filename)`, + "like", + sql`lower(${pattern}) escape '\\'`, + ); + } + // Default to only showing ready items if (options.status !== "all") { query = query.where("status", "=", options.status ?? "ready"); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 388e474ba..9f48ce899 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2554,6 +2554,7 @@ export class EmDashRuntime { cursor?: string; limit?: number; mimeType?: string | readonly string[]; + q?: string; }) { return handleMediaList(this.db, params); } diff --git a/packages/core/tests/integration/database/media-filename-search.test.ts b/packages/core/tests/integration/database/media-filename-search.test.ts new file mode 100644 index 000000000..712301218 --- /dev/null +++ b/packages/core/tests/integration/database/media-filename-search.test.ts @@ -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"]); + }); +});