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:
+458
-52
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user