Add search, bulk actions, undo, and storage usage to File Management

- Manage Library search: find any course/folder by name anywhere in
  the tree instead of expanding levels one by one; results carry the
  same Move/Rename/Hide actions.
- Bulk select: every row gets a checkbox, so items found via search or
  expanded across different tree levels can be hidden, shown, or moved
  to the same destination together in one batch.
- Undo last action: a "Last action: ... [Undo]" bar appears after any
  move/rename (Sort Unsorted apply, Bulk Rename apply, a Manage
  Library move/rename, a bulk move) and reverses the whole batch.
  Deliberately never covers Hide/Show (already a one-click toggle) or
  Delete (permanent by design) - only ever move/rename, which are
  trivially reversible. Re-checks each item before reversing it, so a
  partial failure reports exactly what did and didn't reverse.
- Storage Usage: disk usage per top-level library folder, largest
  first, with a simple proportional bar; a manual scan since it reads
  every file's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 14:41:26 -04:00
co-authored by Claude Sonnet 5
parent 71d6158c60
commit ba8d7e2a1b
4 changed files with 780 additions and 54 deletions
+458 -52
View File
@@ -286,6 +286,70 @@
overflow-wrap: break-word;
word-break: break-word;
}
.undo-bar {
display: none;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
font-size: 0.9em;
}
.manage-bulk-bar {
display: none;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 0.9em;
}
.manage-bulk-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.storage-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--border-color);
}
.storage-row:first-of-type {
border-top: none;
}
.storage-row-name {
flex: 0 0 160px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-bar-track {
flex: 1;
height: 10px;
background: var(--bg-primary);
border-radius: 5px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 5px;
}
.storage-row-size {
flex: 0 0 70px;
text-align: right;
color: var(--text-muted);
font-size: 0.9em;
}
.toolbar {
display: flex;
align-items: center;
@@ -501,6 +565,11 @@
<h1>{{ icons.icon('folder', 24) }} File Management</h1>
<p class="subtitle">Sort new courses, refresh the library cache, bulk-rename, hide/move/rename anything already filed, check for duplicates, and clean up stale references. Nothing changes on disk until you review and apply it.</p>
<div id="undo-bar" class="undo-bar">
<span id="undo-label"></span>
<button class="btn btn-secondary btn-sm" id="undo-btn" onclick="performUndo()">{{ icons.icon('refresh', 14) }} Undo</button>
</div>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Sort Unsorted</h2>
<p class="section-desc">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure.</p>
@@ -555,16 +624,45 @@
</div>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Manage Library</h2>
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS.</p>
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS. Select several to hide or move at once.</p>
<div class="card">
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
<div class="toolbar" style="margin-bottom: 10px;">
<input type="text" id="manage-search-input" class="text-input" placeholder="Search courses and folders…" oninput="onManageSearchInput()">
<button class="btn btn-secondary btn-sm" id="manage-search-clear" onclick="clearManageSearch()" style="display: none;">Clear</button>
</div>
<div id="manage-bulk-bar" class="manage-bulk-bar">
<span id="manage-bulk-count"></span>
<div class="manage-bulk-actions">
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(true)">Hide Selected</button>
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(false)">Show Selected</button>
<button class="btn btn-secondary btn-sm" onclick="startBulkMove()">Move Selected…</button>
<button class="btn btn-secondary btn-sm" onclick="clearManageSelection()">Clear</button>
</div>
</div>
<div id="manage-bulk-move-panel"></div>
<div id="manage-browse-wrap">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div>
<div id="manage-search-results" style="display: none;"></div>
</div>
<h2 class="section-title">{{ icons.icon('bar-chart', 18) }} Storage Usage</h2>
<p class="section-desc">Disk usage per top-level library folder, largest first - handy for spotting what's eating the most space on the NAS. Scans every file in the library, so it's a manual trigger rather than something that runs automatically.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
<span id="storage-status"></span>
</div>
<div id="storage-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Courses</h2>
@@ -878,6 +976,7 @@
console.warn('Unsorted apply errors:', data.errors);
}
loadCategories().then(() => scanUnsorted());
checkUndoStatus();
})
.catch(() => {
btn.disabled = false;
@@ -1015,6 +1114,7 @@
loadHiddenList();
loadCategories();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
checkUndoStatus();
})
.catch(() => {
status.style.color = 'var(--error)';
@@ -1022,6 +1122,254 @@
});
}
// ---- Undo last move/rename ----
function checkUndoStatus() {
fetch('/api/library/undo-last')
.then(r => r.json())
.then(data => {
const bar = document.getElementById('undo-bar');
if (data.available) {
document.getElementById('undo-label').textContent = `Last action: ${data.label}`;
bar.style.display = 'flex';
} else {
bar.style.display = 'none';
}
})
.catch(() => {});
}
function performUndo() {
const btn = document.getElementById('undo-btn');
btn.disabled = true;
fetch('/api/library/undo-last', { method: 'POST' })
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
btn.disabled = false;
if (!ok || !data.success) {
alert((data && data.error) || 'Undo failed');
checkUndoStatus();
return;
}
if (data.active_course_reset) {
alert('That undo affected your active course - reload the main page to pick it up.');
}
const failed = (data.results || []).filter(r => !r.success);
if (failed.length) {
alert(`Undid ${data.reversed} of ${data.total} - ${failed.length} couldn't be reversed (see console).`);
console.warn('Undo errors:', failed);
}
loadHiddenList();
loadCategories();
refreshManageView();
checkUndoStatus();
})
.catch(() => {
btn.disabled = false;
alert('Could not reach the server.');
});
}
// ---- Manage Library: bulk selection ----
let selectedManagePaths = new Map(); // path -> name
function curateCheckboxHtml(item) {
const checked = selectedManagePaths.has(item.path) ? 'checked' : '';
return `<input type="checkbox" class="sort-row-check curate-select" data-path="${escapeAttr(item.path)}" data-name="${escapeAttr(item.name)}" ${checked} onclick="event.stopPropagation(); toggleManageSelect(this)">`;
}
function toggleManageSelect(checkboxEl) {
const path = checkboxEl.dataset.path;
if (checkboxEl.checked) {
selectedManagePaths.set(path, checkboxEl.dataset.name);
} else {
selectedManagePaths.delete(path);
}
updateManageBulkBar();
}
function clearManageSelection() {
selectedManagePaths.clear();
document.querySelectorAll('.curate-select').forEach(cb => { cb.checked = false; });
updateManageBulkBar();
}
function updateManageBulkBar() {
const bar = document.getElementById('manage-bulk-bar');
const count = selectedManagePaths.size;
document.getElementById('manage-bulk-count').textContent = `${count} selected`;
bar.style.display = count > 0 ? 'flex' : 'none';
if (count === 0) {
document.getElementById('manage-bulk-move-panel').innerHTML = '';
}
}
function isManageSearchActive() {
return document.getElementById('manage-search-results').style.display !== 'none';
}
// Bulk actions can touch items selected from several different
// expanded tree levels (or search results) at once, so there's no
// single "level" left to refresh afterward - just reload whichever
// view is currently showing from scratch.
function refreshManageView() {
if (isManageSearchActive()) {
runManageSearch();
} else {
loadCurateLevel(null, document.getElementById('curate-tree'), true);
}
}
function bulkSetHidden(hidden) {
const paths = Array.from(selectedManagePaths.keys());
if (!paths.length) return;
fetch('/api/hidden-paths/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths, hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
clearManageSelection();
loadHiddenList();
refreshManageView();
}
})
.catch(() => {});
}
function startBulkMove() {
const panelWrap = document.getElementById('manage-bulk-move-panel');
if (panelWrap.innerHTML.trim()) {
panelWrap.innerHTML = '';
return;
}
const count = selectedManagePaths.size;
const optionsHtml = categoryOptionsHtml();
panelWrap.innerHTML = `
<div class="curate-move-panel">
<div class="sort-field">
<label>Move ${count} selected item${count === 1 ? '' : 's'} to</label>
<div class="dest-picker">
${ICON_SVGS.folder}
<select class="move-dest-select">
${optionsHtml}
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
</div>
<div class="curate-move-actions">
<button class="btn btn-sm">Move</button>
<button class="btn btn-secondary btn-sm">Cancel</button>
</div>
<div class="curate-move-status"></div>
</div>
`;
const panel = panelWrap.querySelector('.curate-move-panel');
const select = panel.querySelector('.move-dest-select');
wireNewFolderFields(select, panel.querySelector('.sort-field'));
if (!optionsHtml) {
select.value = NEW_FOLDER_VALUE;
panel.querySelector('.new-folder-name').focus();
}
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
cancelBtn.onclick = () => { panelWrap.innerHTML = ''; };
moveBtn.onclick = () => submitBulkMove(panel);
}
function submitBulkMove(panel) {
const select = panel.querySelector('.move-dest-select');
const fieldsWrap = panel.querySelector('.sort-field');
const status = panel.querySelector('.curate-move-status');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
if (!destination) {
status.style.color = 'var(--error)';
status.textContent = 'Choose or type a destination first.';
if (usingNew) panel.querySelector('.new-folder-name').focus();
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Moving…';
const sources = Array.from(selectedManagePaths.keys());
fetch('/api/library/bulk-move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources, destination })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
status.style.color = 'var(--error)';
status.textContent = (data && data.error) || 'Move failed';
return;
}
if (data.active_course_reset) {
alert('Your active course was moved - reload the main page to pick it up under its new location.');
}
const failedCount = (data.results || []).filter(r => !r.success).length;
clearManageSelection();
loadHiddenList();
loadCategories();
refreshManageView();
checkUndoStatus();
if (failedCount) {
alert(`Moved ${data.moved}, ${failedCount} failed - see console for details.`);
console.warn('Bulk move errors:', data.results.filter(r => !r.success));
}
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Manage Library: search ----
let manageSearchDebounce = null;
function onManageSearchInput() {
clearTimeout(manageSearchDebounce);
const query = document.getElementById('manage-search-input').value.trim();
document.getElementById('manage-search-clear').style.display = query ? 'inline-flex' : 'none';
if (!query) {
clearManageSearch();
return;
}
manageSearchDebounce = setTimeout(runManageSearch, 250);
}
function runManageSearch() {
const query = document.getElementById('manage-search-input').value.trim();
if (!query) return;
const resultsEl = document.getElementById('manage-search-results');
document.getElementById('manage-browse-wrap').style.display = 'none';
resultsEl.style.display = 'block';
fetch(`/api/library/manage-search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => renderManageSearchResults(data.results || []))
.catch(() => {
resultsEl.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function clearManageSearch() {
document.getElementById('manage-search-input').value = '';
document.getElementById('manage-search-clear').style.display = 'none';
document.getElementById('manage-search-results').style.display = 'none';
document.getElementById('manage-search-results').innerHTML = '';
document.getElementById('manage-browse-wrap').style.display = 'block';
}
function renderManageSearchResults(items) {
const container = document.getElementById('manage-search-results');
if (!items.length) {
container.innerHTML = '<div class="curate-empty-hint">No matches.</div>';
return;
}
container.innerHTML = items.map(item => curateRowHtml(item, false)).join('');
}
// ---- Manage Library: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
@@ -1063,45 +1411,54 @@
});
}
// Shared by the tree (expandable=true) and search results
// (expandable=false, since a flat hit list has nowhere to expand
// into) - keeps Move/Rename/Hide/select behavior identical either
// way instead of maintaining two versions of the same row markup.
function curateRowHtml(item, expandable) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const checkbox = curateCheckboxHtml(item);
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course' || !expandable) {
return `
<div class="curate-row ${hiddenClass}">
${checkbox}
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${escapeHtml(item.name)}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
${checkbox}
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${escapeHtml(item.name)}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
container.innerHTML = data.items.map(item => curateRowHtml(item, true)).join('');
}
function toggleCurateDir(rowEl, path) {
@@ -1124,6 +1481,22 @@
loadCurateLevel(path, content, false);
}
// Refreshes wherever a single-row action happened: the tree level
// it was in (root or one expanded folder), or re-runs the search
// if that's the active view. Bulk actions use refreshManageView()
// instead, since a bulk selection can span several levels at once.
function refreshManageContainer(btnEl) {
if (isManageSearchActive()) {
runManageSearch();
return;
}
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
@@ -1134,13 +1507,7 @@
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
refreshManageContainer(btnEl);
}
})
.catch(() => {});
@@ -1202,10 +1569,8 @@
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
refreshManageContainer(btnEl);
checkUndoStatus();
})
.catch(() => {
input.replaceWith(nameSpan);
@@ -1300,10 +1665,8 @@
}
loadHiddenList();
loadCategories();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
refreshManageContainer(btnEl);
checkUndoStatus();
})
.catch(() => {
status.style.color = 'var(--error)';
@@ -1311,6 +1674,48 @@
});
}
// ---- Storage Usage ----
function scanStorageUsage() {
const btn = document.getElementById('storage-scan-btn');
const status = document.getElementById('storage-status');
const results = document.getElementById('storage-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning… this reads every file, may take a moment.';
results.innerHTML = '';
fetch('/api/library/storage-usage')
.then(r => r.json())
.then(data => {
btn.disabled = false;
if (data.error) {
status.style.color = 'var(--error)';
status.textContent = data.error;
return;
}
const entries = data.entries || [];
if (!entries.length) {
status.style.color = 'var(--text-muted)';
status.textContent = 'Nothing found.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = `Total: ${data.total_human}`;
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
results.innerHTML = entries.map(e => `
<div class="storage-row">
<span class="storage-row-name">${escapeHtml(e.name)}</span>
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
<span class="storage-row-size">${escapeHtml(e.human)}</span>
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Duplicate Courses ----
function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn');
@@ -1578,6 +1983,7 @@
loadCategories().then(() => scanUnsorted());
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
checkUndoStatus();
</script>
</body>
</html>