mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
feat(folder_library): Folder Library core plugin (#610)
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter, live search), wired into the classic v2 toolbar and the v3 Songs page. Includes the screen.js IIFE dedup (unified surface factory) and review fixes: path-traversal guard on /song/move, folder-delete data-loss fix, plural /api/plugins/<id> namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle <kyle.j.t@live.co.uk> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kyle
Claude Opus 4.8
parent
d1f7f12293
commit
3b2d83d406
+35
-2
@@ -1082,7 +1082,7 @@ const _LIB_VIEW_KEY = 'feedBack.libView';
|
||||
const _LIB_SORT_KEY = 'feedBack.libSort';
|
||||
const _LIB_FORMAT_KEY = 'feedBack.libFormat';
|
||||
const _LIB_PROVIDER_KEY = 'feedBack.libProvider';
|
||||
const _LIB_VIEW_VALUES = new Set(['grid', 'tree']);
|
||||
const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
||||
const _LIB_SORT_VALUES = new Set([
|
||||
'artist', 'artist-desc', 'title', 'title-desc',
|
||||
'recent', 'year-desc', 'year', 'tuning',
|
||||
@@ -1760,8 +1760,20 @@ function setLibView(view) {
|
||||
document.getElementById('lib-tree').classList.toggle('hidden', view !== 'tree');
|
||||
document.querySelectorAll('.lib-grid-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'grid'));
|
||||
document.querySelectorAll('.lib-tree-ctrl').forEach(el => el.classList.toggle('hidden', view !== 'tree'));
|
||||
document.querySelectorAll('.lib-nontree-ctrl').forEach(el => el.classList.toggle('hidden', view === 'tree'));
|
||||
document.getElementById('view-grid-btn').className = `px-3 py-2.5 text-sm transition ${view === 'grid' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
|
||||
document.getElementById('view-tree-btn').className = `px-3 py-2.5 text-sm transition ${view === 'tree' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
|
||||
// Folder view
|
||||
const folderTreeEl = document.getElementById('lib-folder-tree');
|
||||
if (folderTreeEl) folderTreeEl.classList.toggle('hidden', view !== 'folder');
|
||||
const folderCtrlEl = document.getElementById('lib-folder-controls');
|
||||
if (folderCtrlEl) folderCtrlEl.classList.toggle('hidden', view !== 'folder');
|
||||
// The folder-view toolbar button only exists in the classic (v2) markup;
|
||||
// setLibView also runs at v3 startup where it's absent, so guard it (the
|
||||
// grid/tree buttons above predate this and exist on both paths).
|
||||
const folderBtnEl = document.getElementById('view-folder-btn');
|
||||
if (folderBtnEl) folderBtnEl.className = `px-3 py-2.5 text-sm transition ${view === 'folder' ? 'text-accent-light' : 'text-gray-600 hover:text-gray-400'}`;
|
||||
if (libView === 'folder' && view !== 'folder') window.folderLibrary?.unload?.();
|
||||
if (view !== 'grid') stopInfiniteScroll();
|
||||
_libEpoch++;
|
||||
// View toggle changes which container `_libNavItems` resolves
|
||||
@@ -1774,11 +1786,32 @@ function setLibView(view) {
|
||||
async function loadLibrary(page) {
|
||||
if (libView === 'grid') {
|
||||
await loadGridPage(page !== undefined ? page : currentPage);
|
||||
} else {
|
||||
} else if (libView === 'tree') {
|
||||
await loadTreeView();
|
||||
} else if (libView === 'folder') {
|
||||
if (window.folderLibrary) await window.folderLibrary.load();
|
||||
}
|
||||
// v3 Songs page manages its own view state independently of libView — if
|
||||
// lib-folder-tree is visible, the folder library must also react to filter changes.
|
||||
if (libView !== 'folder' && window.folderLibrary) {
|
||||
const treeEl = document.getElementById('lib-folder-tree');
|
||||
if (treeEl && !treeEl.classList.contains('hidden')) {
|
||||
await window.folderLibrary.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folder Library: filter bridge ─────────────────────────────────────────
|
||||
// Serialises the active lib filter state as URL params so the plugin can pass
|
||||
// them to /api/plugins/folder_library/tree — the same pattern grid and tree
|
||||
// views use when sending filter params to their own backend endpoints.
|
||||
window.feedBackLibFilterParams = function() {
|
||||
var p = new URLSearchParams();
|
||||
_applyLibFiltersToParams(p);
|
||||
return p.toString();
|
||||
};
|
||||
|
||||
|
||||
async function _fetchJsonOrThrow(url) {
|
||||
const resp = await fetch(url);
|
||||
const raw = await resp.text();
|
||||
|
||||
+7
-1
@@ -103,10 +103,13 @@
|
||||
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Grid controls -->
|
||||
<select id="lib-sort" onchange="sortLibrary()"
|
||||
class="lib-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="artist">Artist A-Z</option>
|
||||
<option value="artist-desc">Artist Z-A</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
@@ -147,6 +150,9 @@
|
||||
<div id="lib-tree" class="space-y-2 hidden">
|
||||
<!-- Tree populated by JS -->
|
||||
</div>
|
||||
<div id="lib-folder-tree" class="space-y-1 hidden">
|
||||
<!-- Folder tree populated by JS when Folders source is active -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
|
||||
|
||||
+50
-3
@@ -216,6 +216,8 @@
|
||||
const treeBtn = document.getElementById('v3-songs-tree-btn');
|
||||
if (gridBtn) gridBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
if (treeBtn) treeBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
const folderBtn = document.getElementById('v3-songs-folder-btn');
|
||||
if (folderBtn) folderBtn.className = 'px-3 py-2 text-sm ' + (state.view === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
updateFilterBadge();
|
||||
}
|
||||
|
||||
@@ -843,6 +845,24 @@
|
||||
function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
|
||||
function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
|
||||
|
||||
// The host loads the Folder Library plugin's screen.js at startup (defining
|
||||
// window.folderLibrary). If it isn't present yet, inject it once; the
|
||||
// plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
|
||||
// promise is memoised so concurrent folder-view switches don't double-inject.
|
||||
let _flLoadPromise = null;
|
||||
function _ensureFolderLibrary() {
|
||||
if (window.folderLibrary) return Promise.resolve();
|
||||
if (_flLoadPromise) return _flLoadPromise;
|
||||
_flLoadPromise = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '/api/plugins/folder_library/screen.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => { _flLoadPromise = null; reject(new Error('Failed to load Folder Library')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _flLoadPromise;
|
||||
}
|
||||
|
||||
function reload() {
|
||||
_clearLibraryScrollSnapshot();
|
||||
// Record the state this fetch reflects so a later sidebar return can
|
||||
@@ -853,9 +873,15 @@
|
||||
// Keep a handle on the load so callers (notably the scroll restore on
|
||||
// screen re-entry) can await page-0 actually landing before paging
|
||||
// deeper. The visibility/scroll resets below stay synchronous.
|
||||
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
|
||||
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
|
||||
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
|
||||
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
|
||||
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
|
||||
if (state.view === 'folder') {
|
||||
_applyMainScrollTop(0);
|
||||
return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
|
||||
}
|
||||
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
|
||||
_applyMainScrollTop(0);
|
||||
return loaded;
|
||||
}
|
||||
@@ -904,7 +930,7 @@
|
||||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button></div>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
|
||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||
@@ -913,6 +939,8 @@
|
||||
'</div></div></div>' +
|
||||
'<div id="v3-songs-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
|
||||
'<div id="v3-songs-tree" class="hidden"></div>' +
|
||||
'<div id="lib-folder-controls" style="display:none"></div>' +
|
||||
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
|
||||
'<div id="v3-songs-sentinel" class="h-8"></div>' +
|
||||
// Filter drawer + overlay
|
||||
'<div id="v3-songs-overlay" class="fixed inset-0 bg-black/50 z-40 hidden"></div>' +
|
||||
@@ -979,14 +1007,17 @@
|
||||
e.stopImmediatePropagation();
|
||||
toggleSelect(card.getAttribute('data-fn'), card);
|
||||
}, true);
|
||||
const setView = (v) => {
|
||||
const setView = async (v) => {
|
||||
state.view = v;
|
||||
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
if (v === 'folder') await _ensureFolderLibrary();
|
||||
return reload();
|
||||
};
|
||||
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
|
||||
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
|
||||
byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
|
||||
// Await the initial load so a caller awaiting render() (the scroll
|
||||
// restore on screen re-entry) sees a populated grid + real state.total
|
||||
// before it tries to page deeper.
|
||||
@@ -1042,6 +1073,8 @@
|
||||
if (state.renderedHash !== _libraryStateHash()) { reload(); return; }
|
||||
document.getElementById('v3-songs-grid')?.classList.toggle('hidden', state.view !== 'grid');
|
||||
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
|
||||
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
|
||||
{ const _fc = document.getElementById('lib-folder-controls'); if (_fc) _fc.style.display = state.view === 'folder' ? 'flex' : 'none'; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1093,6 +1126,20 @@
|
||||
reload: reload,
|
||||
search: search,
|
||||
setQuery: (q) => { state.q = q || ''; },
|
||||
getSort: () => state.sort,
|
||||
getArtist: () => state.artist,
|
||||
getAlbum: () => state.album,
|
||||
filterParams: () => {
|
||||
const f = state.filters;
|
||||
const p = new URLSearchParams();
|
||||
if (f.arr_has.length) p.set('arrangements_has', f.arr_has.join(','));
|
||||
if (f.arr_lacks.length) p.set('arrangements_lacks', f.arr_lacks.join(','));
|
||||
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
|
||||
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
|
||||
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
||||
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
|
||||
return p.toString();
|
||||
},
|
||||
_scrollHelpers: {
|
||||
SCROLL_STATE_KEY,
|
||||
buildLibraryStateHash,
|
||||
|
||||
Reference in New Issue
Block a user