Skip to content

fix: add filename search and type filtering to the media library#1227

Merged
ascorbic merged 3 commits into
mainfrom
fix/1221-media-search
Jun 2, 2026
Merged

fix: add filename search and type filtering to the media library#1227
ascorbic merged 3 commits into
mainfrom
fix/1221-media-search

Conversation

@scottbuscemi
Copy link
Copy Markdown
Collaborator

@scottbuscemi scottbuscemi commented May 29, 2026

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)

  • mediaListQuery gains an optional q (trimmed, 1–200 chars).
  • MediaRepository.findMany applies a case-insensitive lower(filename) LIKE %q% filter (SQLite/Postgres parity), with LIKE wildcards (% _ \) in the term escaped via ESCAPE '\' (reusing the existing escapeLike helper). A filename substring covers both filename and extension (e.g. .png); the existing mimeType filter covers file type.
  • Threaded through handleMediaList, the runtime, and the GET /_emdash/api/media route.

Admin (@emdash-cms/admin)

  • fetchMediaList sends q.
  • Media Library page: a filename search box (debounced 300ms) + a type filter Select (All / Images / Video / Audio / Documents → mapped to mimeType).
  • Media picker (MediaPickerModal): its search box now also shows for the local library and drives a debounced q. 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 with mimeType, wildcard-escape. MediaLibrary tests 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:json clean; emdash + @emdash-cms/admin typecheck pass.

Manual verification:

  1. Media page with many assets → type part of a filename (or an extension like .png); list filters. Change the type filter; list narrows by kind.
  2. In a content entity, open the media picker → search the local library by filename.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code, review-fix commits)

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.

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-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: e8958ca

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs e8958ca Jun 02 2026, 10:30 AM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 29, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1227

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1227

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1227

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1227

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1227

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1227

emdash

npm i https://pkg.pr.new/emdash@1227

create-emdash

npm i https://pkg.pr.new/create-emdash@1227

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1227

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1227

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1227

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1227

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1227

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1227

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1227

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1227

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1227

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1227

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1227

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1227

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1227

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1227

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1227

commit: e8958ca

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache e8958ca Jun 02 2026, 10:31 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground e8958ca Jun 02 2026, 10:31 AM

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label May 31, 2026
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LIKE pattern is built from a regex escape and passed through Kysely’s sql tag, 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 escapeLike patterns.

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.
@ascorbic ascorbic marked this pull request as ready for review June 2, 2026 08:55
Copilot AI review requested due to automatic review settings June 2, 2026 08:55
@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label Jun 2, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 2, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-escaped filename substring filter in MediaRepository.findMany.
  • Admin: send q from fetchMediaList, 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.

Comment on lines +74 to +80
// 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]);
Comment on lines 49 to 53
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);

Comment on lines 205 to 224
// 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,
}),
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MediaPickerModal now hoists mediaQueryKey into a single const shared by useInfiniteQuery and the optimistic setQueryData dimension 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 MediaLibrary list-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 LIKE pattern is built via escapeLike() and passed through Kysely’s sql tag as a parameterized value. The ESCAPE '\' 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 checks media:read). A changeset is present.
  • Query-key stability: The MediaPage infinite query uses a structured key ["media", { search, mime }]; the picker uses ["media", mimeFilterString, debouncedSearch]. Both are stable and consistent with their queryFn arguments. The dimensionsMutation in the picker now shares the exact key with its useInfiniteQuery, 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 mimeType filtering, 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.

Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Query key mismatch in MediaPickerModal — fixed. The dimensionsMutation now computes mediaQueryKey once and shares it between useInfiniteQuery and queryClient.setQueryData, so optimistic dimension updates land on the correct cache entry.
  2. Redundant inline escaping — not present. The repository uses the existing escapeLike helper; there is no duplicate escaping logic.

What I checked:

  • SQL safety: the pattern is built with escapeLike() and passed through Kysely’s sql tag 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.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jun 2, 2026
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).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Jun 2, 2026
@ascorbic ascorbic merged commit a40e455 into main Jun 2, 2026
37 checks passed
@ascorbic ascorbic deleted the fix/1221-media-search branch June 2, 2026 11:56
@emdashbot emdashbot Bot mentioned this pull request Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug - Media - Searchability and Filtering (in Media Library and from Content Entity)

3 participants