diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 09:53:46 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 09:53:46 +0300 |
| commit | 0f18fcd2dd98c9261f8c8ce1be1d895184567357 (patch) | |
| tree | 2e87e69938772699e18a344e4db8e97c46dbe047 /web/js | |
| parent | 92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (diff) | |
feat: hybrid cache-busting for thumbnails and covers
- Add Cache-Control: no-cache headers to thumbnail and cover endpoints
(GET /api/media/{id}/thumbnail, GET /api/sets/{id}/cover,
GET /s/{token}/thumbnail) so browsers revalidate instead of
serving stale cached images after regeneration.
- Add frontend cache-busting via ?t=Date.now() after folder cover
regeneration so the browser fetches the newly overwritten image.
- Replace toolbar filters with inline search syntax (min:, max:,
tag:, like:, type:, sort:, minsize:, maxsize:) for faster filtering.
- Remove dedicated toolbar and advanced filter panel; consolidate
all filtering into the search bar.
- Expand filter state to support filesize_min/filesize_max.
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/app.js | 78 | ||||
| -rw-r--r-- | web/js/keyboard.js | 9 | ||||
| -rw-r--r-- | web/js/search.js | 80 | ||||
| -rw-r--r-- | web/js/state.js | 12 |
4 files changed, 122 insertions, 57 deletions
diff --git a/web/js/app.js b/web/js/app.js index 307190f..76436bb 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -2,7 +2,7 @@ import { API } from './api.js'; import { initKeyboard } from './keyboard.js'; import { initSelection, clearSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js'; import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } from './player.js'; -import { initSearch, focusSearch, trigger as triggerSearch } from './search.js'; +import { initSearch, focusSearch, trigger as triggerSearch, parseQuery } from './search.js'; import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js'; import { initThemes } from './themes.js'; import { initNotes, open as openNotes } from './notes.js'; @@ -76,8 +76,10 @@ async function initApp() { initSelection(); initSearch({ onChange: (q) => { - state.filters.search = q; + const parsed = parseQuery(q); + Object.assign(state.filters, parsed); state.folderPath = ''; + console.log('[search] raw:', q, 'parsed:', parsed, 'filters:', state.filters); loadMedia(); }, input: document.getElementById('search-input'), @@ -126,7 +128,6 @@ async function initApp() { toggleDetach: () => toggleDetach(), download: downloadSelected, help: toggleHelp, - toolbar: toggleToolbar, backspace: () => { navigateBack(); }, sidebar: toggleSidebar, upload: () => showUpload(), @@ -136,8 +137,6 @@ async function initApp() { sharesNavDown: () => sharesNav(1), sharesCopy: copySelectedShare, sharesDelete: deleteSelectedShare, - focusMinDuration: () => focusFilter('filter-min-duration'), - focusMaxDuration: () => focusFilter('filter-max-duration'), }); initNotes(() => toast('Note saved')); initAdmin(); @@ -147,21 +146,6 @@ async function initApp() { initShares(); initMediaInfo(); - // Filters - document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; state.folderPath = ''; loadMedia(); }); - document.getElementById('filter-favorites')?.addEventListener('click', (e) => { - state.filters.favorites = !state.filters.favorites; - e.target.classList.toggle('active', state.filters.favorites); - state.folderPath = ''; - loadMedia(); - }); - document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; state.folderPath = ''; loadMedia(); }); - document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; state.folderPath = ''; loadMedia(); }); - document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; state.folderPath = ''; loadMedia(); }); - document.getElementById('filter-toggle')?.addEventListener('click', () => { - document.getElementById('filter-advanced')?.classList.toggle('hidden'); - }); - // Sidebar close document.getElementById('menu-close')?.addEventListener('click', () => { document.getElementById('sidebar')?.classList.remove('open'); @@ -304,7 +288,7 @@ async function loadMedia() { // When viewing a single set with no filters/search, use the browse endpoint // so that subfolders are presented as folders to navigate into. - if (singleSetId && !setIds && !isShuffle() && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) { + if (singleSetId && !setIds && !isShuffle() && !hasActiveFilters()) { const data = await API.browse(singleSetId, state.folderPath); updateBreadcrumb(data.current_path); setMedia(mediaWithBrowsePath(data.media || [], data.current_path || '')); @@ -313,6 +297,7 @@ async function loadMedia() { resultCount.textContent = `${total} items`; } else { breadcrumb?.classList.add('hidden'); + const sort = isShuffle() ? 'random' : (state.filters.sort || 'name'); const params = { set_id: setIds ? '' : String(singleSetId || state.selectedSetId || ''), set_ids: setIds, @@ -322,7 +307,9 @@ async function loadMedia() { tags: state.filters.tags || '', min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '', max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '', - sort: isShuffle() ? 'random' : 'name', + filesize_min: state.filters.minFileSize ? String(parseInt(state.filters.minFileSize, 10) * 1024 * 1024) : '', + filesize_max: state.filters.maxFileSize ? String(parseInt(state.filters.maxFileSize, 10) * 1024 * 1024) : '', + sort, limit: '1000', }; const data = await API.media(params); @@ -404,7 +391,18 @@ function renderBrowse(data) { if (!setId) return; const name = b.closest('.folder-card')?.dataset.name; const folder = state.folderPath ? `${state.folderPath}/${name}` : name; - try { await API.regenCover(setId, folder); toast('Folder cover regenerated'); loadMedia(); } + try { + await API.regenCover(setId, folder); + toast('Folder cover regenerated'); + await loadMedia(); + // Force refresh the folder cover image to bypass browser cache + const folderCard = document.querySelector(`.folder-card[data-name="${CSS.escape(name)}"]`); + const img = folderCard?.querySelector('img'); + if (img) { + const base = img.src.split('?')[0]; + img.src = `${base}?t=${Date.now()}`; + } + } catch (err) { toast(err.message || 'Cover failed', 'error'); } }); }); @@ -575,7 +573,17 @@ function canTraverseBrowseFolders() { } function hasActiveFilters() { - return !!(state.filters.search || state.filters.type || state.filters.favorites || state.filters.tags || state.filters.minDuration || state.filters.maxDuration); + return !!( + state.filters.search || + state.filters.type || + state.filters.favorites || + state.filters.tags || + state.filters.minDuration || + state.filters.maxDuration || + state.filters.minFileSize || + state.filters.maxFileSize || + state.filters.sort + ); } async function findCrossFolderPlayable(delta, preferCurrent) { @@ -982,28 +990,6 @@ function toggleHelp() { modal.classList.toggle('open'); } -// Toolbar / sidebar / search visibility toggles -function toggleToolbar() { - const toolbar = document.getElementById('toolbar'); - toolbar?.classList.toggle('hidden'); -} - -function toggleSidebar() { - const sidebar = document.getElementById('sidebar'); - const page = document.querySelector('.page'); - if (!sidebar) return; - const open = sidebar.classList.toggle('open'); - page?.classList.toggle('has-sidebar', open); -} - -function focusFilter(id) { - const el = document.getElementById(id); - if (!el) return; - document.getElementById('filter-advanced')?.classList.remove('hidden'); - el.focus(); - el.select(); -} - function showSearch() { const bar = document.getElementById('search-bar'); bar?.classList.remove('hidden'); diff --git a/web/js/keyboard.js b/web/js/keyboard.js index 3a1ee25..a9ebf2a 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -160,7 +160,6 @@ export function initKeyboard(handlers) { handlers.search?.(e); break; case 'i': handlers.mediaInfo?.(e); break; - case 't': handlers.toolbar?.(e); break; case 'd': handlers.toggleDetach?.(e); break; case 'D': handlers.download?.(e); break; case 'u': handlers.upload?.(e); break; @@ -169,14 +168,6 @@ export function initKeyboard(handlers) { e.preventDefault(); handlers.help?.(e); break; - case ',': - e.preventDefault(); - handlers.focusMinDuration?.(e); - break; - case '.': - e.preventDefault(); - handlers.focusMaxDuration?.(e); - break; } }); } diff --git a/web/js/search.js b/web/js/search.js index dfb698a..b337027 100644 --- a/web/js/search.js +++ b/web/js/search.js @@ -26,10 +26,88 @@ export function initSearch({ onChange, input, clearBtn }) { export function focusSearch() { const input = document.getElementById('search-input'); - if (input) input.focus(); + if (input) { + input.focus(); + input.select(); + } } export function trigger(query) { clearTimeout(_debounceTimer); _onChange(query); } + +/** + * Parse a raw search string into structured filters and free-text search. + * + * Supported syntax: + * min:30 – minimum duration in minutes + * max:55 – maximum duration in minutes + * tag:a,b – comma-separated tag names + * like:1 – only favorited items + * type:video – media type (video | audio) + * sort:name – sort order (name | date | duration | play_count | random) + * minsize:10 – minimum file size in MB + * maxsize:500 – maximum file size in MB + * + * Anything that does not match key:value becomes free-text search. + */ +export function parseQuery(raw) { + const filters = { + type: '', + favorites: false, + tags: '', + sort: '', + minDuration: '', + maxDuration: '', + minFileSize: '', + maxFileSize: '', + search: '', + }; + const tokens = []; + // Split by whitespace while respecting double-quoted strings. + const parts = raw.match(/(".*?"|\S+)/g) || []; + for (const part of parts) { + const m = part.match(/^([a-zA-Z_]+):(.+)$/); + if (m) { + const key = m[1].toLowerCase(); + let value = m[2]; + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + switch (key) { + case 'min': + filters.minDuration = value; + break; + case 'max': + filters.maxDuration = value; + break; + case 'tag': + filters.tags = value; + break; + case 'like': + filters.favorites = ['1', 'true', 'yes'].includes(value); + break; + case 'type': + filters.type = value; + break; + case 'sort': + filters.sort = value; + break; + case 'minsize': + filters.minFileSize = value; + break; + case 'maxsize': + filters.maxFileSize = value; + break; + default: + tokens.push(part); + break; + } + } else { + tokens.push(part); + } + } + filters.search = tokens.join(' ').trim(); + return filters; +} diff --git a/web/js/state.js b/web/js/state.js index 4d580b9..f0879c1 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -4,7 +4,17 @@ export const state = { selectedSetId: null, selectedSetIds: [], // multi-selection media: [], - filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' }, + filters: { + type: '', + search: '', + favorites: false, + tags: '', + sort: '', + minDuration: '', + maxDuration: '', + minFileSize: '', + maxFileSize: '', + }, isAdmin: false, folderPath: '', // current subfolder path within the selected set sharesCurrentRow: -1, |
