fix: add filename search and type filtering to the media library#1227
Conversation
The Media Library page and the content-editor media picker had no way to search or filter local media, making large libraries hard to use. Add a `q` filename substring filter (case-insensitive, matches extensions, LIKE wildcards escaped) to the media list endpoint alongside the existing mimeType filter, and wire both surfaces: the Media page gets a search box plus a type filter (images/video/audio/documents) and the picker searches the local library by filename. Closes #1221.
🦋 Changeset detectedLatest commit: e8958ca The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | e8958ca | Jun 02 2026, 10:30 AM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | e8958ca | Jun 02 2026, 10:31 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | e8958ca | Jun 02 2026, 10:31 AM |
There was a problem hiding this comment.
The approach is sound: add a server-side q parameter for case-insensitive filename substring search (with LIKE wildcards escaped), expose it in the admin via a debounced input on the Media Library and media picker, and add a MIME-type filter dropdown on the Media Library. This fits EmDash’s architecture—thin route wrappers, handler business logic, Kysely parameterized queries, and TanStack Query in the admin.
I checked:
- SQL safety: the
LIKEpattern is built from a regex escape and passed through Kysely’ssqltag, so it is parameterized; no injection risk. - AGENTS.md conventions: Lingui macros are used for all user-facing strings, Tailwind classes are logical (RTL-safe), the API returns the standard
{ items, nextCursor }envelope, auth is unchanged, and a changeset is present. - Cross-cutting concerns: query-key stability in the admin, cache invalidation, and existing
escapeLikepatterns.
I found one concrete regression introduced by the PR: the MediaPickerModal dimensionsMutation performs an optimistic cache update with setQueryData, but its hard-coded query key no longer matches the useInfiniteQuery key that now includes the debounced search term. This means dimension updates silently fail to update the cached list data. I also noted a minor maintainability issue: the repository file already contains an escapeLike helper, but the PR adds a redundant inline escaping mechanism.
Otherwise the diff is clean and the tests cover the new behavior.
- Scope the list-view 'Type' header assertion with exact text matching so it no longer collides with the 'All types' filter label under the browser-mode locator's substring matching. - Hoist the media query key into one const so useInfiniteQuery and the optimistic setQueryData dimension update share an exact key. - Reuse the existing escapeLike() helper for filename search instead of a redundant inline LIKE-wildcard regex.
There was a problem hiding this comment.
Pull request overview
Adds server-side filename search (q) and coarse MIME-type filtering to the media library surfaces (admin Media page and content-editor media picker), resolving the usability gap from #1221 for large media libraries.
Changes:
- Core: extend media list query schema + API handler/runtime plumbing to accept
q, and apply a case-insensitive, wildcard-escapedfilenamesubstring filter inMediaRepository.findMany. - Admin: send
qfromfetchMediaList, add a debounced “Search by filename…” input and a type filter for the local library, and include the search term in the media picker’s infinite-query key (so optimistic updates land correctly). - Tests: add cross-dialect integration tests covering case-insensitive substring matching, extension matching, MIME+q combination, and wildcard escaping.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/tests/integration/database/media-filename-search.test.ts | Adds dialect-aware integration coverage for q filename substring search + wildcard escaping. |
| packages/core/src/emdash-runtime.ts | Threads optional q through the runtime media-list handler signature. |
| packages/core/src/database/repositories/media.ts | Implements case-insensitive filename substring filtering with escaped LIKE wildcards. |
| packages/core/src/astro/routes/api/media.ts | Passes validated q from the API route into handleMediaList. |
| packages/core/src/api/schemas/media.ts | Extends mediaListQuery with optional trimmed q (1–200 chars). |
| packages/core/src/api/handlers/media.ts | Forwards q into MediaRepository.findMany. |
| packages/admin/tests/components/MediaLibrary.test.tsx | Updates an assertion collision and adds tests for local search + type filter callbacks. |
| packages/admin/src/router.tsx | Adds local-library search + MIME filter state to the Media page query key and request options. |
| packages/admin/src/lib/api/media.ts | Sends q for local media listing requests. |
| packages/admin/src/components/MediaPickerModal.tsx | Debounces local search, includes it in the infinite-query key, and sends q for local library searches. |
| packages/admin/src/components/MediaLibrary.tsx | Adds local filename search UI + type filter Select and debounced reporting to the parent. |
| .changeset/fix-media-search.md | Declares patch releases and documents the new search/filter behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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 value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; | ||
| if (value) params.set("mimeType", value); | ||
| } | ||
| if (options?.search) params.set("q", options.search); | ||
|
|
| // Fetch local media list (cursor-paginated so libraries beyond the | ||
| // first page remain selectable from the picker, not just the first 50). | ||
| // setQueryData is exact-match, so the optimistic dimension update below | ||
| // must share this exact key with the query that populates it. | ||
| const mediaQueryKey = ["media", filters?.join(",") ?? "", debouncedSearch.trim()]; | ||
| const { | ||
| data: localData, | ||
| isLoading: localLoading, | ||
| fetchNextPage: fetchNextLocalPage, | ||
| hasNextPage: hasNextLocalPage, | ||
| isFetchingNextPage: isFetchingNextLocalPage, | ||
| } = useInfiniteQuery({ | ||
| queryKey: ["media", filters?.join(",") ?? ""], | ||
| queryKey: mediaQueryKey, | ||
| queryFn: ({ pageParam }) => | ||
| fetchMediaList({ | ||
| mimeType: filters, | ||
| cursor: pageParam, | ||
| limit: 100, | ||
| search: debouncedSearch.trim() || undefined, | ||
| }), |
There was a problem hiding this comment.
This is a re-review of PR #1227 after the author addressed the previous feedback in commit 2b4789f6. The approach remains sound: adding a server-side q parameter for case-insensitive filename substring search (with LIKE wildcards escaped via the existing escapeLike helper), threading it through handlers/routes/runtime, and surfacing it in the admin via debounced inputs on both the Media Library and media picker, plus a MIME-type filter dropdown.
What changed since the last review:
- The
MediaPickerModalnow hoistsmediaQueryKeyinto a single const shared byuseInfiniteQueryand the optimisticsetQueryDatadimension update, fixing the silent cache miss identified in the prior review. - The repository reuses the existing
escapeLike()helper instead of a redundant inline regex. - The
MediaLibrarylist-view test uses exact text matching ({ exact: true }) for the "Type" header to avoid colliding with the new "All types" filter label.
What I checked:
- SQL safety: The
LIKEpattern is built viaescapeLike()and passed through Kysely’ssqltag as a parameterized value. TheESCAPE '\'clause is present. No injection risk. - AGENTS.md conventions: All user-facing strings are wrapped with Lingui macros. Tailwind uses logical (RTL-safe) classes. The API returns the standard
{ items, nextCursor }envelope. Auth is unchanged (list route already checksmedia:read). A changeset is present. - Query-key stability: The
MediaPageinfinite query uses a structured key["media", { search, mime }]; the picker uses["media", mimeFilterString, debouncedSearch]. Both are stable and consistent with theirqueryFnarguments. ThedimensionsMutationin the picker now shares the exact key with itsuseInfiniteQuery, so optimistic updates land on the live cache entry. - Cache invalidation: Upload/delete mutations invalidate
["media"], which TanStack Query v5 matches as a prefix against all media query keys (including the structured keys here). This is consistent with the existing pattern used elsewhere in the admin. - Tests: The new backend integration tests cover substring matching, extension matching, combined
mimeTypefiltering, and wildcard escape. The frontend tests cover debounced search reporting and MIME filter selection. The assertions look correct.
No remaining issues. The diff is clean and the fixes properly address the prior review findings.
There was a problem hiding this comment.
This PR adds a server-side q parameter for case-insensitive filename substring search (with LIKE wildcards escaped via the existing escapeLike helper), exposes it through the admin UI with a debounced search input, and adds a MIME-type filter dropdown on the Media Library. It also wires the search through the content-editor media picker.
Approach assessment: The approach is sound and fits EmDash’s architecture well. The backend change is minimal and safe—a single optional q parameter threaded from the route through the handler to the repository, where it becomes a parameterized lower(filename) LIKE lower(?) clause. The admin changes use TanStack Query correctly and reuse the existing useDebouncedValue hook.
Prior review findings:
- Query key mismatch in
MediaPickerModal— fixed. ThedimensionsMutationnow computesmediaQueryKeyonce and shares it betweenuseInfiniteQueryandqueryClient.setQueryData, so optimistic dimension updates land on the correct cache entry. - Redundant inline escaping — not present. The repository uses the existing
escapeLikehelper; there is no duplicate escaping logic.
What I checked:
- SQL safety: the pattern is built with
escapeLike()and passed through Kysely’ssqltag as a parameterized value;ESCAPE '\'is present. No injection risk. - AGENTS.md conventions: all user-facing strings are wrapped with Lingui macros (
t\`,), Tailwind classes use logical properties (ms-,ps-,start-*), the API returns the standard{ items, nextCursor }` envelope, auth is unchanged, and a changeset is present. - Cross-cutting concerns:
invalidateQueries({ queryKey: ["media"] })uses prefix matching and correctly invalidates the more specific keys introduced by this PR. Tests cover SQLite and PostgreSQL parity (describeEachDialect). - Edge cases: empty/whitespace-only search terms are handled client-side (
search || undefined) and rejected by zod validation (trim().min(1)). The wildcard-escape test verifies literal matching for%and_.
The diff is clean, the tests are comprehensive, and there are no remaining issues.
Trim and clamp the filename search term to the server-accepted range (max 200 chars) in fetchMediaList and cap the search inputs with maxLength, so a long or whitespace-only query no longer triggers an avoidable 400 (addresses review feedback).
What does this PR do?
Closes #1221.
Media items couldn't be searched or filtered — on the Media Library page (
/_emdash/admin/media) or in the content-editor media picker. With large libraries this made finding an asset impractical. The issue asked for search by file type, extension, and filename.Fix:
Backend (
emdash)mediaListQuerygains an optionalq(trimmed, 1–200 chars).MediaRepository.findManyapplies a case-insensitivelower(filename) LIKE %q%filter (SQLite/Postgres parity), with LIKE wildcards (% _ \) in the term escaped viaESCAPE '\'(reusing the existingescapeLikehelper). A filename substring covers both filename and extension (e.g..png); the existingmimeTypefilter covers file type.handleMediaList, the runtime, and theGET /_emdash/api/mediaroute.Admin (
@emdash-cms/admin)fetchMediaListsendsq.mimeType).MediaPickerModal): its search box now also shows for the local library and drives a debouncedq. The optimistic dimensions cache update now shares the exact infinite-query key (search term included) so it lands on the live cache entry instead of silently no-opping.Testing:
packages/core/tests/integration/database/media-filename-search.test.ts(describeEachDialect): substring + case-insensitive, extension match, combined withmimeType, wildcard-escape.MediaLibrarytests cover query/MIME-filter reporting (the grid/list-toggle assertion was made exact so it no longer collides with the "All types" label). Full core suite green;pnpm lint:jsonclean;emdash+@emdash-cms/admintypecheck pass.Manual verification:
.png); list filters. Change the type filter; list narrows by kind.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
fix/1221-media-search. Updated automatically when the playground redeploys.