summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 09:53:46 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 09:53:46 +0300
commit0f18fcd2dd98c9261f8c8ce1be1d895184567357 (patch)
tree2e87e69938772699e18a344e4db8e97c46dbe047 /web/js
parent92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (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.js78
-rw-r--r--web/js/keyboard.js9
-rw-r--r--web/js/search.js80
-rw-r--r--web/js/state.js12
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,