diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
| commit | 8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch) | |
| tree | 5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /web/js | |
| parent | 59c8870061465142306e7a6e5d3fcb344686cfe9 (diff) | |
Refine media browsing and set covers
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/api.js | 1 | ||||
| -rw-r--r-- | web/js/app.js | 133 | ||||
| -rw-r--r-- | web/js/detach.js | 2 | ||||
| -rw-r--r-- | web/js/detachPopup.js | 50 | ||||
| -rw-r--r-- | web/js/keyboard.js | 40 | ||||
| -rw-r--r-- | web/js/playback.js | 36 | ||||
| -rw-r--r-- | web/js/podcasts.js | 24 | ||||
| -rw-r--r-- | web/js/search.js | 48 | ||||
| -rw-r--r-- | web/js/selection.js | 2 | ||||
| -rw-r--r-- | web/js/state.js | 1 | ||||
| -rw-r--r-- | web/js/tests/media-pagination.test.js | 56 | ||||
| -rw-r--r-- | web/js/tests/search-parser.test.js | 33 | ||||
| -rw-r--r-- | web/js/views/help.js | 2 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 243 | ||||
| -rw-r--r-- | web/js/views/playback-nav.js | 6 | ||||
| -rw-r--r-- | web/js/views/sets.js | 83 | ||||
| -rw-r--r-- | web/js/views/tags.js | 52 |
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); |
