From 60da126e724ee5741189d2882780dbb40a5b3da1 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 23 Jul 2026 10:45:26 -0500 Subject: [PATCH] feat(content): server-side search, type filter, and sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content discovery was client-side only, scoped to the items already rendered on the current page — searching "logo" on page 1 couldn't find logos on page 2 or in another folder. Server (GET /api/content): - ?q= text search on filename (LIKE, workspace-wide — a search ignores the open folder so nothing is missed). LIKE metacharacters are escaped so a filename with % or _ matches literally. - ?type=video|image|youtube|web — youtube (video/youtube) and web (other remote_url) are split from plain uploaded video/image so the four UI buckets map cleanly. - ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user input into ORDER BY); default keeps the legacy newest-first ordering. Frontend (content-library): - Type filter + sort dropdowns; search debounced (300ms) and now hits the server instead of filtering the DOM. - Result count shown while a search/type filter is active. - en/es i18n. api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while searching to match the server's workspace-wide behaviour. Test: content-search-filter-sort.test.js mounts the real router and covers substring match, LIKE-escape (literal %), the type buckets, name/size sort, the ORDER BY injection guard, and combined filters. Suite 541/541. Closes #214 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/api.js | 18 +++- frontend/js/i18n/en.js | 10 ++ frontend/js/i18n/es.js | 10 ++ frontend/js/views/content-library.js | 57 +++++++++--- server/routes/content.js | 32 ++++++- .../test/content-search-filter-sort.test.js | 91 +++++++++++++++++++ 6 files changed, 196 insertions(+), 22 deletions(-) create mode 100644 server/test/content-search-filter-sort.test.js diff --git a/frontend/js/api.js b/frontend/js/api.js index 0489e19..a20f241 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -54,11 +54,19 @@ export const api = { }), // Content - getContent: (folderId, includeExpired = false) => { - const exp = includeExpired ? '&include_expired=1' : ''; - if (folderId === undefined) return request(`/content${exp ? '?' + exp.slice(1) : ''}`); - const q = folderId === null ? 'root' : encodeURIComponent(folderId); - return request(`/content?folder_id=${q}${exp}`); + getContent: (folderId, includeExpired = false, opts = {}) => { + const p = new URLSearchParams(); + // #214: a text search spans the whole workspace, so folder_id is only sent when + // NOT searching (the server also ignores folder_id when q is present, but keeping + // the client in sync avoids a misleading URL). + const searching = opts.q && opts.q.trim(); + if (!searching && folderId !== undefined) p.set('folder_id', folderId === null ? 'root' : folderId); + if (includeExpired) p.set('include_expired', '1'); + if (searching) p.set('q', opts.q.trim()); + if (opts.type && opts.type !== 'all') p.set('type', opts.type); + if (opts.sort) p.set('sort', opts.sort); + const qs = p.toString(); + return request(`/content${qs ? '?' + qs : ''}`); }, getContentItem: (id) => request(`/content/${id}`), deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 15ccb2e..f3091b5 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -186,6 +186,16 @@ export default { 'content.youtube_add_btn': 'Add YouTube Video', // Search / folders 'content.search_placeholder': 'Search content...', + 'content.filter_type_all': 'All types', + 'content.filter_type_video': 'Videos', + 'content.filter_type_image': 'Images', + 'content.filter_type_youtube': 'YouTube', + 'content.filter_type_web': 'Web / remote', + 'content.sort_newest': 'Newest first', + 'content.sort_oldest': 'Oldest first', + 'content.sort_name': 'Name A–Z', + 'content.sort_size': 'Largest first', + 'content.result_count': '{count} result(s)', 'content.new_folder_btn': '+ New Folder', 'content.breadcrumb_root': 'All Content', 'content.rename_btn': 'Rename', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 2b610c8..d251a68 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -149,6 +149,16 @@ export default { 'content.youtube_name_placeholder': 'Nombre para mostrar (opcional)', 'content.youtube_add_btn': 'Agregar video de YouTube', 'content.search_placeholder': 'Buscar contenido...', + 'content.filter_type_all': 'Todos los tipos', + 'content.filter_type_video': 'Videos', + 'content.filter_type_image': 'Imágenes', + 'content.filter_type_youtube': 'YouTube', + 'content.filter_type_web': 'Web / remoto', + 'content.sort_newest': 'Más recientes primero', + 'content.sort_oldest': 'Más antiguos primero', + 'content.sort_name': 'Nombre A–Z', + 'content.sort_size': 'Más grandes primero', + 'content.result_count': '{count} resultado(s)', 'content.new_folder_btn': '+ Nueva carpeta', 'content.breadcrumb_root': 'Todo el contenido', 'content.rename_btn': 'Renombrar', diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 104283a..80cfcb3 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -96,7 +96,21 @@ export function render(container) {
- + + + +