diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-02 11:45:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-02 11:45:27 +0300 |
| commit | 23d017ff90abfe7aeea7fb43619d7469e3ec0c2a (patch) | |
| tree | 95380288fa9d7ee362e43f5f91dcfd3370a79469 /web | |
| parent | 5275a0a4cec129bc1ea66beab934bf472f7a7fd2 (diff) | |
more on this
Diffstat (limited to 'web')
| -rw-r--r-- | web/css/layout.css | 25 | ||||
| -rw-r--r-- | web/css/player.css | 3 | ||||
| -rw-r--r-- | web/index.html | 6 | ||||
| -rw-r--r-- | web/js/api.js | 13 | ||||
| -rw-r--r-- | web/js/app.js | 190 | ||||
| -rw-r--r-- | web/js/keyboard.js | 1 | ||||
| -rw-r--r-- | web/js/selection.js | 2 | ||||
| -rw-r--r-- | web/js/state.js | 1 |
8 files changed, 215 insertions, 26 deletions
diff --git a/web/css/layout.css b/web/css/layout.css index 7f0e19e..e52a083 100644 --- a/web/css/layout.css +++ b/web/css/layout.css @@ -103,6 +103,31 @@ body { max-width: 100%; } +/* Breadcrumb bar */ +.breadcrumb-bar { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; + font-size: 0.85rem; +} +.breadcrumb-bar.hidden { display: none !important; } +.breadcrumb-bar button { + background: transparent; + border: none; + color: var(--accent); + padding: 0.15rem 0.25rem; + cursor: pointer; + font: inherit; + border-radius: var(--radius-sm); +} +.breadcrumb-bar button:hover { background: var(--accent-soft); } +.breadcrumb-bar .breadcrumb-sep { color: var(--text-muted); user-select: none; } + +/* Folder card placeholder */ +.folder-card .thumb-wrap .placeholder { font-size: 3rem; } + /* Media grid */ .media-grid { display: grid; diff --git a/web/css/player.css b/web/css/player.css index 7c7b189..5628dfd 100644 --- a/web/css/player.css +++ b/web/css/player.css @@ -15,6 +15,9 @@ .page.has-sidebar .player { inset: auto 0 0 var(--sidebar-width); } +.page.has-sidebar .player.collapsed { + inset: auto auto 0 0; +} .player.is-fullscreen { inset: 0; background: var(--player-stage-bg); diff --git a/web/index.html b/web/index.html index e2b58a4..c5a124f 100644 --- a/web/index.html +++ b/web/index.html @@ -63,6 +63,9 @@ <p class="text-muted text-sm">Press <kbd>s</kbd> to browse sets, <kbd>/</kbd> to search, or <kbd>?</kbd> for help.</p> </div> + <!-- Breadcrumb bar for folder navigation --> + <div id="breadcrumb-bar" class="breadcrumb-bar hidden"></div> + <!-- Media grid: hidden until a set is loaded or search is active --> <div id="media-grid" class="media-grid hidden" tabindex="0" aria-label="Media grid"></div> </div> @@ -113,11 +116,12 @@ <tr><td><kbd>k</kbd> <kbd>j</kbd></td><td>Navigate media list (vim-style)</td></tr> <tr><td><kbd>←</kbd> <kbd>→</kbd></td><td>Switch sets / pages</td></tr> <tr><td><kbd>h</kbd> <kbd>l</kbd></td><td>Switch sets / pages (vim-style)</td></tr> - <tr><td><kbd>Enter</kbd></td><td>Open selected media</td></tr> + <tr><td><kbd>Enter</kbd></td><td>Open selected media / enter folder</td></tr> <tr><td><kbd>Space</kbd> <kbd>p</kbd></td><td>Play / pause</td></tr> <tr><td><kbd>f</kbd></td><td>Toggle fullscreen</td></tr> <tr><td><kbd>c</kbd></td><td>Collapse / expand player stage</td></tr> <tr><td><kbd>Esc</kbd></td><td>Exit fullscreen, deselect item</td></tr> + <tr><td><kbd>Backspace</kbd></td><td>Go up one folder</td></tr> <tr><td><kbd>r</kbd></td><td>Toggle shuffle</td></tr> <tr><td><kbd>s</kbd></td><td>Toggle sets sidebar</td></tr> <tr><td><kbd>/</kbd></td><td>Focus search bar</td></tr> diff --git a/web/js/api.js b/web/js/api.js index dc8ee8d..8e80a0d 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -58,7 +58,18 @@ export const API = { tags: () => api('/api/tags'), download: (id) => api(`/api/media/${id}/download`), regenThumbnail: (id) => api(`/api/media/${id}/thumbnail`, { method: 'POST' }), - regenCover: (setId) => api(`/api/sets/${setId}/cover`, { method: 'POST' }), + browse: (setId, parent) => { + const params = new URLSearchParams(); + if (parent) params.set('parent', parent); + const s = params.toString(); + return api(`/api/sets/${setId}/browse${s ? '?' + s : ''}`); + }, + regenCover: (setId, folder = '') => { + const params = new URLSearchParams(); + if (folder) params.set('folder', folder); + const s = params.toString(); + return api(`/api/sets/${setId}/cover${s ? '?' + s : ''}`, { method: 'POST' }); + }, upload: (setId, formData) => api(`/api/sets/${setId}/upload`, { method: 'POST', body: formData }), addTag: (id, tag) => api(`/api/media/${id}/tags`, { method: 'POST', body: { tag } }), removeTag: (id, tag) => api(`/api/media/${id}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' }), diff --git a/web/js/app.js b/web/js/app.js index 2d0e15d..2bd6aef 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -73,6 +73,7 @@ async function initApp() { initSearch({ onChange: (q) => { state.filters.search = q; + state.folderPath = ''; loadMedia(); }, input: document.getElementById('search-input'), @@ -96,7 +97,13 @@ async function initApp() { toggleSetSelect: () => toggleSetSelection(), enter: () => { const el = currentElement(); - if (el) { selectByElement(el); playSelected(); } + if (!el) return; + selectByElement(el); + if (el.classList.contains('folder-card')) { + enterFolder(el.dataset.name); + } else { + playSelected(); + } }, playPause: () => togglePlay(), fullscreen: () => toggleFullscreen(), @@ -110,6 +117,7 @@ async function initApp() { download: downloadSelected, help: toggleHelp, toolbar: toggleToolbar, + backspace: () => { navigateBack(); }, sidebar: toggleSidebar, upload: () => showUpload(), focusMinDuration: () => focusFilter('filter-min-duration'), @@ -122,15 +130,16 @@ async function initApp() { initHelp(); // Filters - document.getElementById('filter-type')?.addEventListener('change', (e) => { state.filters.type = e.target.value; loadMedia(); }); + 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; loadMedia(); }); - document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; loadMedia(); }); - document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; 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'); }); @@ -162,6 +171,15 @@ async function initApp() { if (el) { selectByElement(el); playSelected(); } }); + // Folder navigation + document.getElementById('media-grid')?.addEventListener('click', (e) => { + const folder = e.target.closest('.folder-card'); + if (folder) { + e.stopPropagation(); + enterFolder(folder.dataset.name); + } + }); + // Tags modal close document.getElementById('tags-close')?.addEventListener('click', closeTagsModal); document.getElementById('tags-modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('tags-modal')) closeTagsModal(); }); @@ -186,6 +204,7 @@ function renderSets() { const id = parseInt(row.dataset.id, 10); state.selectedSetIds = [id]; state.selectedSetId = id; + state.folderPath = ''; updateSetRowsUI(); loadMedia(); }); @@ -229,6 +248,7 @@ function toggleSetSelection() { } else { state.selectedSetId = null; } + state.folderPath = ''; updateSetRowsUI(); loadMedia(); } @@ -244,6 +264,7 @@ function setSetByDelta(dx) { const nextIdx = (idx + dx + state.sets.length) % state.sets.length; state.selectedSetId = state.sets[nextIdx].id; state.selectedSetIds = [state.selectedSetId]; + state.folderPath = ''; setActiveSet(state.selectedSetId); loadMedia(); } @@ -255,31 +276,154 @@ async function loadMedia() { grid.classList.remove('hidden'); hint?.classList.add('hidden'); grid.innerHTML = '<p class="text-muted text-sm grid-full">Loading...</p>'; + + const resultCount = document.getElementById('result-count'); + const breadcrumb = document.getElementById('breadcrumb-bar'); + try { const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; - const singleSetId = state.selectedSetIds.length === 1 ? String(state.selectedSetIds[0]) : (state.selectedSetId ?? ''); - const params = { - set_id: setIds ? '' : singleSetId, - set_ids: setIds, - type: state.filters.type, - search: state.filters.search, - favorites: state.filters.favorites ? 'true' : '', - 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', - limit: '200', - }; - const data = await API.media(params); - const list = Array.isArray(data) ? data : data?.media || []; - setMedia(list); - renderGrid(list); - document.getElementById('result-count').textContent = `${list.length} items`; + const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null; + + // 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 && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) { + const data = await API.browse(singleSetId, state.folderPath); + updateBreadcrumb(data.current_path); + setMedia(data.media || []); + renderBrowse(data); + const total = (data.media?.length || 0) + (data.folders?.length || 0); + resultCount.textContent = `${total} items`; + } else { + breadcrumb?.classList.add('hidden'); + const params = { + set_id: setIds ? '' : String(singleSetId || state.selectedSetId || ''), + set_ids: setIds, + type: state.filters.type, + search: state.filters.search, + favorites: state.filters.favorites ? 'true' : '', + 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', + limit: '200', + }; + const data = await API.media(params); + const list = Array.isArray(data) ? data : data?.media || []; + setMedia(list); + renderGrid(list); + resultCount.textContent = `${list.length} items`; + } } catch (err) { grid.innerHTML = `<p class="error-message grid-full">${escapeHtml(err.message)}</p>`; } } +function updateBreadcrumb(currentPath) { + const el = document.getElementById('breadcrumb-bar'); + if (!el) return; + const setName = state.sets.find((s) => s.id === state.selectedSetId)?.name || 'Set'; + const parts = currentPath ? currentPath.split('/') : []; + + let html = `<button class="breadcrumb-root" data-folder="">${escapeHtml(setName)}</button>`; + let accumulated = ''; + for (const part of parts) { + accumulated = accumulated ? `${accumulated}/${part}` : part; + html += ` <span class="breadcrumb-sep">/</span> <button class="breadcrumb-part" data-folder="${accumulated}">${escapeHtml(part)}</button>`; + } + el.innerHTML = html; + el.classList.remove('hidden'); + el.querySelectorAll('button').forEach((b) => { + b.addEventListener('click', () => { + state.folderPath = b.dataset.folder; + loadMedia(); + }); + }); +} + +function enterFolder(name) { + state.folderPath = state.folderPath ? `${state.folderPath}/${name}` : name; + loadMedia(); +} + +function navigateBack() { + if (!state.folderPath) return; + const last = state.folderPath.lastIndexOf('/'); + if (last < 0) { + state.folderPath = ''; + } else { + state.folderPath = state.folderPath.substring(0, last); + } + loadMedia(); +} + +function renderBrowse(data) { + const grid = document.getElementById('media-grid'); + if (!grid) return; + const folders = data.folders || []; + const media = data.media || []; + if (!folders.length && !media.length) { + grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>'; + return; + } + const folderHtml = folders.map((f, i) => renderFolder(f, i)).join(''); + const mediaHtml = media.map((m, i) => renderItem(m, i + folders.length)).join(''); + grid.innerHTML = folderHtml + mediaHtml; + + grid.querySelectorAll('.folder-card').forEach((el) => { + el.addEventListener('click', () => enterFolder(el.dataset.name)); + }); + + grid.querySelectorAll('.folder-card [data-action="regen-folder-cover"]').forEach((b) => { + b.addEventListener('click', async (e) => { + e.stopPropagation(); + const setId = state.selectedSetId; + 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(); } + catch (err) { toast(err.message || 'Cover failed', 'error'); } + }); + }); + + grid.querySelectorAll('.media-card, .media-row').forEach((el) => { + el.addEventListener('click', () => { selectByElement(el); }); + const playBtn = el.querySelector('[data-action="play"]'); + const favBtn = el.querySelector('[data-action="favorite"]'); + const noteBtn = el.querySelector('[data-action="notes"]'); + const downloadBtn = el.querySelector('[data-action="download"]'); + const tagBtn = el.querySelector('[data-action="tags"]'); + const thumbBtn = el.querySelector('[data-action="regen-thumb"]'); + playBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); playSelected(); }); + favBtn?.addEventListener('click', (e) => { e.stopPropagation(); toggleFavorite(el.dataset.id, favBtn); }); + noteBtn?.addEventListener('click', (e) => { e.stopPropagation(); openNotesForSelected(); }); + downloadBtn?.addEventListener('click', (e) => { e.stopPropagation(); window.open(`/api/media/${el.dataset.id}/download`, '_blank'); }); + tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); }); + thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); }); + }); +} + +function renderFolder(folder, index) { + const name = typeof folder === 'string' ? folder : folder.name; + const hasCover = typeof folder === 'object' && folder?.has_cover; + const coverImg = hasCover + ? `<img src="/api/sets/${state.selectedSetId}/cover?folder=${encodeURIComponent(state.folderPath ? `${state.folderPath}/${name}` : name)}" alt="" loading="lazy">` + : null; + return ` + <div class="media-card folder-card" data-name="${escapeHtml(name)}" data-index="${index}" tabindex="0" role="button" aria-label="Folder ${escapeHtml(name)}"> + <div class="thumb-wrap"> + ${hasCover ? coverImg : '<span class="placeholder">📁</span>'} + <div class="card-actions"> + <button class="icon-btn btn-sm" data-action="regen-folder-cover" title="Regenerate cover">🔄</button> + </div> + </div> + <div class="meta"> + <div class="title">${escapeHtml(name)}</div> + <div class="subtitle">Folder</div> + </div> + </div> + `; +} + function renderGrid(items) { const grid = document.getElementById('media-grid'); if (!grid) return; diff --git a/web/js/keyboard.js b/web/js/keyboard.js index 0e937b0..62dc652 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -76,6 +76,7 @@ export function initKeyboard(handlers) { break; case 'f': handlers.fullscreen?.(e); break; case 'Escape': handlers.escape?.(e); break; + case 'Backspace': handlers.backspace?.(e); break; case 'r': handlers.shuffle?.(e); break; case 's': handlers.sidebar?.(e); break; case '/': diff --git a/web/js/selection.js b/web/js/selection.js index c83fd81..6ee4a58 100644 --- a/web/js/selection.js +++ b/web/js/selection.js @@ -11,7 +11,7 @@ export function initSelection() { } function refreshCards() { - return Array.from(document.querySelectorAll('.media-card, .media-row')); + return Array.from(document.querySelectorAll('.media-card, .media-row, .folder-card')); } // Compute grid geometry from the rendered cards. diff --git a/web/js/state.js b/web/js/state.js index 6597c00..e8cc632 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -6,6 +6,7 @@ export const state = { media: [], filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' }, isAdmin: false, + folderPath: '', // current subfolder path within the selected set }; export function getState() { return state; } |
