summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
commit8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch)
tree5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /web/js
parent59c8870061465142306e7a6e5d3fcb344686cfe9 (diff)
Refine media browsing and set covers
Diffstat (limited to 'web/js')
-rw-r--r--web/js/api.js1
-rw-r--r--web/js/app.js133
-rw-r--r--web/js/detach.js2
-rw-r--r--web/js/detachPopup.js50
-rw-r--r--web/js/keyboard.js40
-rw-r--r--web/js/playback.js36
-rw-r--r--web/js/podcasts.js24
-rw-r--r--web/js/search.js48
-rw-r--r--web/js/selection.js2
-rw-r--r--web/js/state.js1
-rw-r--r--web/js/tests/media-pagination.test.js56
-rw-r--r--web/js/tests/search-parser.test.js33
-rw-r--r--web/js/views/help.js2
-rw-r--r--web/js/views/media-grid.js243
-rw-r--r--web/js/views/playback-nav.js6
-rw-r--r--web/js/views/sets.js83
-rw-r--r--web/js/views/tags.js52
17 files changed, 653 insertions, 159 deletions
diff --git a/web/js/api.js b/web/js/api.js
index 66fac59..9b0436e 100644
--- a/web/js/api.js
+++ b/web/js/api.js
@@ -44,6 +44,7 @@ export const API = {
login: (username, password) => api('/api/login', { method: 'POST', body: { username, password } }),
logout: () => api('/api/logout', { method: 'POST' }),
bootstrap: (username, password) => api('/api/bootstrap', { method: 'POST', body: { username, password } }),
+ config: () => api('/api/config'),
sets: () => api('/api/sets'),
media: (qs) => {
const params = new URLSearchParams();
diff --git a/web/js/app.js b/web/js/app.js
index b673d91..cfa43f1 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -17,7 +17,6 @@ import {
stopAndClose,
zoomIn as playerZoomIn,
zoomOut as playerZoomOut,
- toggleSlideshow as playerToggleSlideshow,
isImageMode as playerIsImageMode,
} from './player.js';
import { initSearch, parseQuery, showSearchHelp } from './search.js';
@@ -28,17 +27,6 @@ import { initAdmin } from './admin.js';
import { initPodcasts } from './podcasts.js';
import { state } from './state.js';
import { initPWA } from './pwa.js';
-import {
- initLightbox,
- open as openLightbox,
- close as closeLightbox,
- isOpen as isLightboxOpen,
- next as lightboxNext,
- prev as lightboxPrev,
- zoomIn as lightboxZoomIn,
- zoomOut as lightboxZoomOut,
- toggleSlideshow as lightboxToggleSlideshow,
-} from './lightbox.js';
import { closeAllModals } from './dom.js';
import { toast } from './utils.js';
import { showAdmin } from './views/admin-status.js';
@@ -48,6 +36,7 @@ import {
loadMedia,
enterFolder,
navigateBack,
+ setMediaPageSize,
} from './views/media-grid.js';
import {
downloadSelected,
@@ -74,8 +63,10 @@ import {
import {
initSets,
renderSets,
+ selectSetByHotkey,
setSetByDelta,
toggleSetSelection,
+ updateSetRowsUI,
} from './views/sets.js';
import {
copySelectedShare,
@@ -153,30 +144,34 @@ function initBootstrap() {
async function initApp() {
initPlayer({
- onNext: (options) => navigatePlayable(1, options),
- onPrevious: (options) => navigatePlayable(-1, options),
+ onNext: (options) => navigatePlayable(options?.delta ?? 1, options),
+ onPrevious: (options) => navigatePlayable(options?.delta ?? -1, options),
});
initSelection();
initSearch({
onChange: (q) => {
const parsed = parseQuery(q);
+ applySearchSet(parsed);
+ delete parsed.set;
Object.assign(state.filters, parsed);
state.folderPath = '';
+ document.dispatchEvent(new CustomEvent('filters:changed'));
console.log('[search] raw:', q, 'parsed:', parsed, 'filters:', state.filters);
loadMedia();
},
input: document.getElementById('search-input'),
clearBtn: document.getElementById('search-clear'),
});
+ document.addEventListener('search:navigate-results', () => navDown());
initShuffle({ onChange: () => loadMedia() });
- initLightbox({ onNavigate: () => {} });
initPlaybackNav({ isShuffle });
initSets({ onLoadMedia: loadMedia });
initMediaGrid({
isShuffle,
- openLightbox,
+ onSetCleared: updateSetRowsUI,
openNotesForSelected,
openTagsForElement,
+ openSet,
playSelected,
regenThumb,
toggleFavorite,
@@ -190,22 +185,45 @@ async function initApp() {
initHelp();
initShares();
initMediaInfo();
- initTags();
+ initTags({
+ onFilterChange: () => {
+ document.dispatchEvent(new CustomEvent('filters:changed'));
+ loadMedia();
+ },
+ });
initChrome();
try {
- const sets = await API.sets();
- state.sets = sets || [];
+ const [cfg, sets] = await Promise.all([
+ API.config().catch(() => null),
+ API.sets(),
+ ]);
+ setMediaPageSize(cfg?.media_page_size);
+ state.sets = (sets || []).slice().sort((a, b) => a.name.localeCompare(b.name));
API.users().then(() => {
state.isAdmin = true;
showAdmin();
}).catch(() => {});
renderSets();
+ await loadMedia();
} catch (err) {
toast(err.message || 'Error loading sets', 'error');
}
}
+function applySearchSet(parsed) {
+ const setQuery = parsed.set?.trim();
+ if (!setQuery) return;
+ const needle = setQuery.toLowerCase();
+ const match = state.sets.find((set) => String(set.id) === setQuery) ||
+ state.sets.find((set) => set.name.toLowerCase() === needle) ||
+ state.sets.find((set) => set.name.toLowerCase().includes(needle));
+ if (!match) return;
+ state.selectedSetId = match.id;
+ state.selectedSetIds = [match.id];
+ updateSetRowsUI();
+}
+
function keyboardHandlers() {
return {
navUp: () => navUp(),
@@ -221,6 +239,7 @@ function keyboardHandlers() {
},
nextSet: () => setSetByDelta(1),
prevSet: () => setSetByDelta(-1),
+ selectSetByHotkey: (key) => selectSetByHotkey(key),
isSidebarOpen: () => document.getElementById('sidebar')?.classList.contains('open'),
isSidebarFocused: () => {
const sidebar = document.getElementById('sidebar');
@@ -228,24 +247,11 @@ function keyboardHandlers() {
},
toggleSetSelect: () => toggleSetSelection(),
enter: () => {
- const el = currentElement();
- if (!el) return;
- selectByElement(el);
- if (el.classList.contains('folder-card')) {
- enterFolder(el.dataset.name);
- return;
- }
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- openLightbox(state.media, media.id);
- } else {
- playSelected();
- }
+ activateGridElement(focusedGridElement() || currentElement() || firstGridElement());
},
playPause: () => togglePlay(),
- nextTrack: () => navigatePlayable(1),
- prevTrack: () => navigatePlayable(-1),
+ nextTrack: () => navigatePlayable(1, { forcePlay: true }),
+ prevTrack: () => navigatePlayable(-1, { forcePlay: true }),
playRandom: () => playRandom(),
mediaInfo: () => toggleMediaInfo(),
fullscreen: () => toggleFullscreen(),
@@ -255,7 +261,6 @@ function keyboardHandlers() {
cycleCropPosition: () => cycleCropPosition(),
escape: () => {
exitFullscreenIfNeeded();
- closeLightbox();
const el = currentElement();
if (el) el.classList.remove('selected');
closeAllModals();
@@ -289,26 +294,58 @@ function keyboardHandlers() {
sharesNavDown: () => sharesNav(1),
sharesCopy: copySelectedShare,
sharesDelete: deleteSelectedShare,
- isLightboxOpen,
isImageMode: () => currentMediaInfo()?.type === 'image',
+ imageFullscreenNavigate: (delta) => {
+ if (!document.fullscreenElement || !playerIsImageMode()) return false;
+ navigatePlayable(delta, { forcePlay: true });
+ return true;
+ },
zoomIn: () => {
- if (isLightboxOpen()) lightboxZoomIn();
- else if (playerIsImageMode()) playerZoomIn();
+ if (playerIsImageMode()) playerZoomIn();
},
zoomOut: () => {
- if (isLightboxOpen()) lightboxZoomOut();
- else if (playerIsImageMode()) playerZoomOut();
+ if (playerIsImageMode()) playerZoomOut();
},
- toggleSlideshow: () => {
- if (isLightboxOpen()) lightboxToggleSlideshow();
- else if (playerIsImageMode()) playerToggleSlideshow();
- },
- lightboxNext,
- lightboxPrev,
- closeLightbox,
};
}
+function focusedGridElement() {
+ const active = document.activeElement;
+ if (!active || typeof active.closest !== 'function') return null;
+ return active.closest('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
+}
+
+function firstGridElement() {
+ return document.querySelector('#media-grid .media-card, #media-grid .media-row, #media-grid .folder-card, #media-grid .set-card');
+}
+
+function activateGridElement(el) {
+ if (!el) return;
+ selectByElement(el);
+ if (el.classList.contains('set-card')) {
+ const id = parseInt(el.dataset.setId, 10);
+ openSet(id);
+ return;
+ }
+ if (el.classList.contains('folder-card')) {
+ enterFolder(el.dataset.name);
+ return;
+ }
+ const idx = parseInt(el.dataset.index, 10);
+ const media = state.media[idx];
+ if (media) playSelected();
+}
+
+function openSet(id) {
+ if (!Number.isFinite(id)) return;
+ state.selectedSetId = id;
+ state.selectedSetIds = [id];
+ state.folderPath = '';
+ state.mediaPage = 0;
+ updateSetRowsUI();
+ loadMedia();
+}
+
function initChrome() {
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
toggleSidebar();
diff --git a/web/js/detach.js b/web/js/detach.js
index fbd1e1b..c216f3f 100644
--- a/web/js/detach.js
+++ b/web/js/detach.js
@@ -19,6 +19,8 @@ export function initDetach(options = {}) {
deps.triggerPrevious?.({ forcePlay: ev.data.play ?? true });
} else if (ev.data.type === 'detach-next') {
deps.triggerNext?.({ forcePlay: ev.data.play ?? true });
+ } else if (ev.data.type === 'detach-nav') {
+ deps.triggerNavigate?.(Number(ev.data.delta || 0), { forcePlay: ev.data.play ?? true });
}
});
}
diff --git a/web/js/detachPopup.js b/web/js/detachPopup.js
index b1dfd87..0efa701 100644
--- a/web/js/detachPopup.js
+++ b/web/js/detachPopup.js
@@ -49,14 +49,21 @@ document.addEventListener('keydown', (ev) => {
const editing = tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable;
if (editing || ev.ctrlKey || ev.metaKey || ev.altKey) return;
+ const imageDelta = fullscreenImageDelta(ev);
+ if (imageDelta) {
+ ev.preventDefault();
+ post({ type: 'detach-nav', delta: imageDelta, play: true });
+ return;
+ }
+
if (ev.shiftKey && ev.code === 'KeyN') {
ev.preventDefault();
- post({ type: 'detach-next', play: currentState().playing });
+ post({ type: 'detach-next', play: shouldContinuePlayback() });
return;
}
if (ev.shiftKey && ev.code === 'KeyP') {
ev.preventDefault();
- post({ type: 'detach-prev', play: currentState().playing });
+ post({ type: 'detach-prev', play: shouldContinuePlayback() });
return;
}
@@ -77,10 +84,10 @@ document.addEventListener('keydown', (ev) => {
togglePlayback();
} else if (ev.key === 'N') {
ev.preventDefault();
- post({ type: 'detach-next', play: currentState().playing });
+ post({ type: 'detach-next', play: shouldContinuePlayback() });
} else if (ev.key === 'P') {
ev.preventDefault();
- post({ type: 'detach-prev', play: currentState().playing });
+ post({ type: 'detach-prev', play: shouldContinuePlayback() });
}
});
@@ -121,7 +128,10 @@ function loadDetachedMedia(payload) {
loadMediaDirect(currentMedia, payload.streamUrl, payload.thumbnailUrl, resumeFrom);
const el = mediaElement();
- if (!el) return;
+ if (!el) {
+ sendState();
+ return;
+ }
if (typeof payload.volume === 'number') el.volume = payload.volume;
el.muted = !!payload.muted;
@@ -190,7 +200,33 @@ function toggleFullscreen() {
}
function mediaElement() {
- return currentMedia?.type === 'audio' ? audio : video;
+ if (currentMedia?.type === 'audio') return audio;
+ if (currentMedia?.type === 'video') return video;
+ return null;
+}
+
+function shouldContinuePlayback() {
+ return currentMedia?.type === 'image' || currentState().playing;
+}
+
+function fullscreenImageDelta(ev) {
+ if (currentMedia?.type !== 'image' || !document.fullscreenElement) return 0;
+ switch (ev.key) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'h':
+ case 'k':
+ case 'PageUp':
+ return ev.key === 'PageUp' ? -10 : -1;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'j':
+ case 'l':
+ case 'PageDown':
+ return ev.key === 'PageDown' ? 10 : 1;
+ default:
+ return 0;
+ }
}
function seekWhenReady(el, seconds) {
@@ -248,7 +284,7 @@ function currentState() {
currentTime: position,
positionReady,
duration: el?.duration || currentMedia?.duration || 0,
- playing: !!el && !el.paused,
+ playing: currentMedia?.type === 'image' || (!!el && !el.paused),
volume: el?.volume ?? 1,
muted: !!el?.muted,
};
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index 49fa358..7752413 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -125,6 +125,17 @@ export function initKeyboard(handlers) {
if (e.ctrlKey || e.metaKey || e.altKey) return; // don't intercept browser shortcuts
+ const imageDelta = fullscreenImageDelta(e);
+ if (imageDelta && handlers.imageFullscreenNavigate?.(imageDelta)) {
+ e.preventDefault();
+ return;
+ }
+
+ if (/^[1-9]$/.test(e.key) && handlers.selectSetByHotkey?.(e.key)) {
+ e.preventDefault();
+ return;
+ }
+
if (e.shiftKey) {
switch (e.key) {
case 'ArrowUp':
@@ -155,15 +166,7 @@ export function initKeyboard(handlers) {
}
if (e.code === 'KeyS') {
e.preventDefault();
- if (e.shiftKey) {
- if (handlers.isImageMode?.()) {
- handlers.toggleSlideshow?.(e);
- } else {
- handlers.share?.(e);
- }
- } else {
- handlers.share?.(e);
- }
+ handlers.share?.(e);
return;
}
@@ -276,3 +279,22 @@ export function initKeyboard(handlers) {
}
});
}
+
+function fullscreenImageDelta(e) {
+ switch (e.key) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'h':
+ case 'k':
+ case 'PageUp':
+ return e.key === 'PageUp' ? -10 : -1;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case 'j':
+ case 'l':
+ case 'PageDown':
+ return e.key === 'PageDown' ? 10 : 1;
+ default:
+ return 0;
+ }
+}
diff --git a/web/js/playback.js b/web/js/playback.js
index b4c31d7..d802252 100644
--- a/web/js/playback.js
+++ b/web/js/playback.js
@@ -119,6 +119,7 @@ export function initPlayer(options = {}) {
localPlaybackState,
triggerPrevious,
triggerNext,
+ triggerNavigate,
});
e.volume?.addEventListener('input', () => {
@@ -390,9 +391,9 @@ export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0)
e.timeElapsed?.classList.add('hidden');
e.timeTotal?.classList.add('hidden');
e.btnPlay?.classList.add('hidden');
- e.btnZoomIn?.classList.remove('hidden');
- e.btnZoomOut?.classList.remove('hidden');
- e.btnSlideshow?.classList.remove('hidden');
+ e.btnZoomIn?.classList.add('hidden');
+ e.btnZoomOut?.classList.add('hidden');
+ e.btnSlideshow?.classList.add('hidden');
e.image.classList.remove('hidden');
e.image.src = streamUrl;
e.player?.classList.add('open', 'has-image');
@@ -470,9 +471,9 @@ function loadMedia(media, resumeFrom = 0) {
e.timeElapsed?.classList.add('hidden');
e.timeTotal?.classList.add('hidden');
e.btnPlay?.classList.add('hidden');
- e.btnZoomIn?.classList.remove('hidden');
- e.btnZoomOut?.classList.remove('hidden');
- e.btnSlideshow?.classList.remove('hidden');
+ e.btnZoomIn?.classList.add('hidden');
+ e.btnZoomOut?.classList.add('hidden');
+ e.btnSlideshow?.classList.add('hidden');
e.image.classList.remove('hidden');
e.image.src = `/api/media/${media.id}/stream`;
e.player?.classList.add('open', 'has-image');
@@ -646,6 +647,25 @@ function triggerNext(options = {}) {
playNext();
}
+function triggerNavigate(delta, options = {}) {
+ const step = Number(delta || 0);
+ if (!step) return;
+ if (step < 0 && previousHandler && step === -1) {
+ previousHandler(options);
+ return;
+ }
+ if (step > 0 && nextHandler) {
+ nextHandler({ ...options, delta: step });
+ return;
+ }
+ if (step < 0 && nextHandler) {
+ nextHandler({ ...options, delta: step });
+ return;
+ }
+ if (step < 0) playPrevious();
+ else playNext();
+}
+
export function playPrevious() {
const list = state.media;
if (!list.length) return;
@@ -675,8 +695,8 @@ export function currentMediaId() { return currentMedia?.id; }
export function currentMediaInfo() { return currentMedia; }
export function isPlaybackActive() {
- if (isDetached()) return detachedIsPlaying();
- return isPlaying;
+ if (isDetached()) return detachedIsPlaying() || currentMedia?.type === 'image';
+ return isPlaying || currentMedia?.type === 'image';
}
export function isImageMode() {
diff --git a/web/js/podcasts.js b/web/js/podcasts.js
index 4692f7b..c8c088e 100644
--- a/web/js/podcasts.js
+++ b/web/js/podcasts.js
@@ -9,7 +9,6 @@ export function initPodcasts() {
const closeBtn = document.getElementById('podcast-close');
const form = document.getElementById('podcast-form');
const urlInput = document.getElementById('podcast-url');
- const nameInput = document.getElementById('podcast-name');
const listEl = document.getElementById('podcast-list');
const adminBtn = document.getElementById('admin-podcasts');
@@ -26,13 +25,11 @@ export function initPodcasts() {
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
- const name = nameInput.value.trim();
if (!url) return;
try {
- await API.subscribePodcast(url, name);
+ await API.subscribePodcast(url, '');
toast('Subscribed to podcast');
urlInput.value = '';
- nameInput.value = '';
refreshPodcasts();
refreshSets();
} catch (err) {
@@ -50,9 +47,9 @@ export function initPodcasts() {
}
listEl.innerHTML = podcasts.map(p =>
`<div class="podcast-feed-row py-1 border-b">
- <img class="podcast-feed-cover" src="/api/sets/${p.id}/cover" alt="" loading="lazy">
- <span class="flex-1">${escapeHtml(p.name)}</span>
- <span class="text-xs text-muted">${escapeHtml(p.root_path)}</span>
+ <img class="podcast-feed-cover" src="${escapeHtml(p.image_url || '/favicon.svg')}" alt="" loading="lazy">
+ <span class="flex-1">${escapeHtml(p.title || p.feed_url)}</span>
+ <span class="text-xs text-muted">${escapeHtml(p.feed_url)}</span>
</div>`
).join('');
} catch (err) {
@@ -70,19 +67,26 @@ export function initPodcasts() {
}
}
-export function renderPodcastEpisodes(grid, episodes) {
+export function renderPodcastEpisodes(grid, episodes, options = {}) {
if (!episodes || !episodes.length) return;
+ const append = (node) => {
+ if (options.before) {
+ grid.insertBefore(node, options.before);
+ } else {
+ grid.appendChild(node);
+ }
+ };
const divider = document.createElement('div');
divider.className = 'grid-divider';
divider.textContent = 'Podcast Episodes';
- grid.appendChild(divider);
+ append(divider);
episodes.forEach(ep => {
const card = document.createElement('div');
card.className = 'media-card episode-card';
card.dataset.id = ep.id;
renderEpisodeCard(card, ep);
- grid.appendChild(card);
+ append(card);
});
}
diff --git a/web/js/search.js b/web/js/search.js
index 4e78b55..31c3004 100644
--- a/web/js/search.js
+++ b/web/js/search.js
@@ -6,10 +6,12 @@ export function initSearch({ onChange, input, clearBtn }) {
_onChange = onChange;
if (!input) return;
input.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- input.value = '';
+ if (e.key === 'Escape' || e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
input.blur();
- trigger('');
+ hideSearch();
+ document.dispatchEvent(new CustomEvent('search:navigate-results'));
}
});
input.addEventListener('input', () => {
@@ -29,6 +31,7 @@ export function initSearch({ onChange, input, clearBtn }) {
}
export function focusSearch() {
+ showSearch();
const input = document.getElementById('search-input');
if (input) {
input.focus();
@@ -41,6 +44,15 @@ export function trigger(query) {
_onChange(query);
}
+export function showSearch() {
+ document.getElementById('search-overlay')?.classList.remove('hidden');
+}
+
+export function hideSearch() {
+ document.getElementById('search-overlay')?.classList.add('hidden');
+ hideSearchHelp();
+}
+
/**
* Parse a raw search string into structured filters and free-text search.
*
@@ -50,6 +62,7 @@ export function trigger(query) {
* tag:a,b – comma-separated tag names
* like:1 – only favorited items
* type:video – media type (video | audio)
+ * set:yoga – select a set by id or name
* sort:name – sort order (name | date | duration | play_count | random)
* minsize:10 – minimum file size in MB
* maxsize:500 – maximum file size in MB
@@ -62,6 +75,7 @@ export function parseQuery(raw) {
favorites: false,
tags: '',
sort: '',
+ set: '',
minDuration: '',
maxDuration: '',
minFileSize: '',
@@ -69,8 +83,7 @@ export function parseQuery(raw) {
search: '',
};
const tokens = [];
- // Split by whitespace while respecting double-quoted strings.
- const parts = raw.match(/(".*?"|\S+)/g) || [];
+ const parts = tokenize(raw);
for (const part of parts) {
const m = part.match(/^([a-zA-Z_]+):(.+)$/);
if (m) {
@@ -95,6 +108,9 @@ export function parseQuery(raw) {
case 'type':
filters.type = value;
break;
+ case 'set':
+ filters.set = value;
+ break;
case 'sort':
filters.sort = value;
break;
@@ -116,6 +132,28 @@ export function parseQuery(raw) {
return filters;
}
+function tokenize(raw) {
+ const parts = [];
+ let current = '';
+ let inQuote = false;
+ for (const ch of raw || '') {
+ if (ch === '"') {
+ inQuote = !inQuote;
+ continue;
+ }
+ if (/\s/.test(ch) && !inQuote) {
+ if (current) {
+ parts.push(current);
+ current = '';
+ }
+ continue;
+ }
+ current += ch;
+ }
+ if (current) parts.push(current);
+ return parts;
+}
+
export function showSearchHelp() {
const el = document.getElementById('search-help');
if (!el) return;
diff --git a/web/js/selection.js b/web/js/selection.js
index 828981a..9134e31 100644
--- a/web/js/selection.js
+++ b/web/js/selection.js
@@ -16,7 +16,7 @@ export function clearSelection() {
}
function refreshCards() {
- return Array.from(document.querySelectorAll('.media-card, .media-row, .folder-card'));
+ return Array.from(document.querySelectorAll('.media-card, .media-row, .folder-card, .set-card'));
}
function syncedIndex(cards) {
diff --git a/web/js/state.js b/web/js/state.js
index f0879c1..b5057b9 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -17,6 +17,7 @@ export const state = {
},
isAdmin: false,
folderPath: '', // current subfolder path within the selected set
+ mediaPage: 0,
sharesCurrentRow: -1,
};
diff --git a/web/js/tests/media-pagination.test.js b/web/js/tests/media-pagination.test.js
new file mode 100644
index 0000000..0d599fc
--- /dev/null
+++ b/web/js/tests/media-pagination.test.js
@@ -0,0 +1,56 @@
+import { paginateItems, setMediaPageSize } from '../views/media-grid.js';
+import { state } from '../state.js';
+
+const failures = [];
+
+function assert(cond, msg) {
+ if (!cond) failures.push(msg || 'assertion failed');
+}
+
+function items(count) {
+ return Array.from({ length: count }, (_, i) => i + 1);
+}
+
+function testDefaultPageSize() {
+ setMediaPageSize(undefined);
+ const first = paginateItems(items(101), 0);
+ assert(first.items.length === 100, 'default first page should contain 100 items');
+ assert(first.hasNext, 'default first page should have next');
+ assert(!first.hasPrev, 'default first page should not have previous');
+
+ const second = paginateItems(items(101), 1);
+ assert(second.items.length === 1, 'default second page should contain remaining item');
+ assert(second.hasPrev, 'default second page should have previous');
+ assert(!second.hasNext, 'default second page should not have next');
+}
+
+function testConfiguredPageSize() {
+ state.mediaPage = 2;
+ setMediaPageSize(25);
+ const page = paginateItems(items(60), 1);
+ assert(state.mediaPage === 0, 'changing page size should reset current media page');
+ assert(page.items.length === 25, 'configured page should contain configured number of items');
+ assert(page.start === 25, 'configured second page should start at item offset 25');
+ assert(page.end === 50, 'configured second page should end at item offset 50');
+}
+
+function testClampsOutOfRangePage() {
+ setMediaPageSize(25);
+ const page = paginateItems(items(60), 99);
+ assert(page.page === 2, 'page should clamp to the last page');
+ assert(page.items.length === 10, 'last page should contain remaining items');
+}
+
+console.log('Running media pagination tests...');
+testDefaultPageSize();
+testConfiguredPageSize();
+testClampsOutOfRangePage();
+
+if (failures.length) {
+ console.error('FAILURES:');
+ failures.forEach((m) => console.error(' - ' + m));
+ process.exit(1);
+} else {
+ console.log('All media pagination tests passed.');
+ process.exit(0);
+}
diff --git a/web/js/tests/search-parser.test.js b/web/js/tests/search-parser.test.js
new file mode 100644
index 0000000..8b8e287
--- /dev/null
+++ b/web/js/tests/search-parser.test.js
@@ -0,0 +1,33 @@
+import { parseQuery } from '../search.js';
+
+const failures = [];
+
+function assert(cond, msg) {
+ if (!cond) failures.push(msg || 'assertion failed');
+}
+
+function testSetFilter() {
+ const parsed = parseQuery('set:yoga');
+ assert(parsed.set === 'yoga', 'set token should parse set name');
+ assert(parsed.search === '', 'set token should not become text search');
+}
+
+function testQuotedSetFilter() {
+ const parsed = parseQuery('set:"Yoga Flow" type:video morning');
+ assert(parsed.set === 'Yoga Flow', 'quoted set token should preserve spaces');
+ assert(parsed.type === 'video', 'other filters should still parse');
+ assert(parsed.search === 'morning', 'free text should still parse');
+}
+
+console.log('Running search parser tests...');
+testSetFilter();
+testQuotedSetFilter();
+
+if (failures.length) {
+ console.error('FAILURES:');
+ failures.forEach((m) => console.error(' - ' + m));
+ process.exit(1);
+} else {
+ console.log('All search parser tests passed.');
+ process.exit(0);
+}
diff --git a/web/js/views/help.js b/web/js/views/help.js
index 05d6949..a85c283 100644
--- a/web/js/views/help.js
+++ b/web/js/views/help.js
@@ -27,7 +27,5 @@ export function toggleHelp() {
}
export function showSearch() {
- const bar = document.getElementById('search-bar');
- bar?.classList.remove('hidden');
focusSearch();
}
diff --git a/web/js/views/media-grid.js b/web/js/views/media-grid.js
index 08435b8..4625eba 100644
--- a/web/js/views/media-grid.js
+++ b/web/js/views/media-grid.js
@@ -5,6 +5,33 @@ import { fmtSize } from '../dom.js';
import { escapeHtml, fmtDur, toast } from '../utils.js';
let callbacks = {};
+let mediaPageSize = 100;
+let lastMediaPageKey = '';
+
+export function setMediaPageSize(value) {
+ const parsed = Number.parseInt(value, 10);
+ mediaPageSize = Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
+ state.mediaPage = 0;
+}
+
+export function paginateItems(items, page, pageSize = mediaPageSize) {
+ const size = Math.max(1, Number.parseInt(pageSize, 10) || 100);
+ const total = items.length;
+ const maxPage = Math.max(0, Math.ceil(total / size) - 1);
+ const current = Math.max(0, Math.min(Number.parseInt(page, 10) || 0, maxPage));
+ const start = current * size;
+ const end = Math.min(start + size, total);
+ return {
+ items: items.slice(start, end),
+ total,
+ page: current,
+ pageSize: size,
+ start,
+ end,
+ hasPrev: current > 0,
+ hasNext: current < maxPage,
+ };
+}
export function initMediaGrid(options = {}) {
callbacks = options;
@@ -14,13 +41,11 @@ export function initMediaGrid(options = {}) {
const el = e.target.closest('.media-card, .media-row');
if (!el) return;
selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- callbacks.openLightbox?.(state.media, media.id);
- } else {
- callbacks.playSelected?.();
+ if (el.classList.contains('set-card')) {
+ callbacks.openSet?.(parseInt(el.dataset.setId, 10));
+ return;
}
+ callbacks.playSelected?.();
});
grid?.addEventListener('click', (e) => {
@@ -51,13 +76,20 @@ export async function loadMedia() {
const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : '';
const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null;
- if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ if (!singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ breadcrumb?.classList.add('hidden');
+ setMedia([]);
+ syncMediaPage('sets');
+ const page = renderSetGrid(state.sets || []);
+ resultCount.textContent = resultText(state.sets.length, page);
+ } else if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ syncMediaPage(`browse:${singleSetId}:${state.folderPath || ''}`);
const data = await API.browse(singleSetId, state.folderPath);
updateBreadcrumb(data.current_path);
setMedia(mediaWithBrowsePath(data.media || [], data.current_path || ''));
- renderBrowse(data);
+ const page = renderBrowse(data);
const total = (data.media?.length || 0) + (data.folders?.length || 0) + (data.episodes?.length || 0);
- resultCount.textContent = `${total} items`;
+ resultCount.textContent = resultText(total, page);
} else {
breadcrumb?.classList.add('hidden');
const sort = callbacks.isShuffle?.() ? 'random' : (state.filters.sort || 'name');
@@ -75,17 +107,29 @@ export async function loadMedia() {
sort,
limit: '1000',
};
+ syncMediaPage(`grid:${JSON.stringify(params)}`);
const data = await API.media(params);
const list = Array.isArray(data) ? data : data?.media || [];
setMedia(list);
- renderGrid(list);
- resultCount.textContent = `${list.length} items`;
+ const page = renderGrid(list);
+ resultCount.textContent = resultText(list.length, page);