release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage

This commit is contained in:
Ryan
2025-11-28 19:24:42 -05:00
committed by GitHub
parent 8fc716387b
commit 1c0ac50048
3 changed files with 451 additions and 295 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## Changes 11/28/2025 (v2.2.1)
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
- Refactor adminStorage breadcrumb builder to construct DOM nodes instead of using innerHTML.
- Rework Storage explorer folder view to render rows via createElement/textContent, avoiding DOM text reinterpreted as HTML.
- Keep deep-delete and pagination behavior unchanged while tightening up XSS/CodeQL concerns.
- Update README feature list to mention disk usage summary and Pro storage explorer (ncdu-style) alongside user groups and client portals.
## Changes 11/28/2025 (v2.2.0) ## Changes 11/28/2025 (v2.2.0)
release(v2.2.0): add storage explorer + disk usage scanner release(v2.2.0): add storage explorer + disk usage scanner

View File

@@ -19,9 +19,10 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
- 🌳 **Scales to huge trees** Tested with **100k+ folders** in the sidebar tree. - 🌳 **Scales to huge trees** Tested with **100k+ folders** in the sidebar tree.
- 🧩 **ONLYOFFICE support (optional)** Edit DOCX/XLSX/PPTX using your own Document Server. - 🧩 **ONLYOFFICE support (optional)** Edit DOCX/XLSX/PPTX using your own Document Server.
- 🌍 **WebDAV** Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP. - 🌍 **WebDAV** Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
- 📊 **Storage / disk usage summary** CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
- 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor. - 🎨 **Polished UI** Dark/light mode, responsive layout, in-browser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.). - 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
- 👥 **User groups & client portals (Pro)** Group-based ACLs and brandable client upload portals. - 👥 **Pro: user groups, client portals & storage explorer** Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features) Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)

View File

@@ -868,45 +868,67 @@ function setBreadcrumb(folderKey) {
const el = document.getElementById('adminStorageBreadcrumb'); const el = document.getElementById('adminStorageBreadcrumb');
if (!el) return; if (!el) return;
// Clear existing content safely
while (el.firstChild) {
el.removeChild(el.firstChild);
}
let parts = []; let parts = [];
if (!folderKey || folderKey === 'root') { if (folderKey && folderKey !== 'root') {
parts = [];
} else {
const clean = folderKey.replace(/^\/+|\/+$/g, ''); const clean = folderKey.replace(/^\/+|\/+$/g, '');
parts = clean ? clean.split('/') : []; parts = clean ? clean.split('/') : [];
} }
const crumbs = []; // Helper: add a simple separator " / "
const appendSeparator = () => {
const sep = document.createElement('span');
sep.className = 'mx-1';
sep.textContent = '/';
el.appendChild(sep);
};
// root crumb // Root crumb
crumbs.push(` const rootBtn = document.createElement('button');
<button type="button" class="btn btn-link btn-sm p-0 admin-storage-bc" data-folder="root"> rootBtn.type = 'button';
<span class="material-icons" style="font-size:16px;vertical-align:middle;color:currentColor;">home</span> rootBtn.className = 'btn btn-link btn-sm p-0 admin-storage-bc';
<span style="vertical-align:middle;">${tf('storage_root_label','root')}</span> rootBtn.dataset.folder = 'root';
</button>
`); const rootIcon = document.createElement('span');
rootIcon.className = 'material-icons';
rootIcon.style.fontSize = '16px';
rootIcon.style.verticalAlign = 'middle';
rootIcon.style.color = 'currentColor';
rootIcon.textContent = 'home';
const rootText = document.createElement('span');
rootText.style.verticalAlign = 'middle';
rootText.textContent = tf('storage_root_label', 'root');
rootBtn.appendChild(rootIcon);
rootBtn.appendChild(rootText);
el.appendChild(rootBtn);
let accum = ''; let accum = '';
parts.forEach((p, idx) => { parts.forEach((p, idx) => {
appendSeparator();
accum = accum ? (accum + '/' + p) : p; accum = accum ? (accum + '/' + p) : p;
const isLast = idx === parts.length - 1; const isLast = idx === parts.length - 1;
if (isLast) { if (isLast) {
crumbs.push(` const span = document.createElement('span');
<span class="mx-1">/</span> span.className = 'fw-semibold';
<span class="fw-semibold">${p}</span> span.textContent = p;
`); el.appendChild(span);
} else { } else {
crumbs.push(` const btn = document.createElement('button');
<span class="mx-1">/</span> btn.type = 'button';
<button type="button" class="btn btn-link btn-sm p-0 admin-storage-bc" data-folder="${accum}"> btn.className = 'btn btn-link btn-sm p-0 admin-storage-bc';
${p} btn.dataset.folder = accum;
</button> btn.textContent = p;
`); el.appendChild(btn);
} }
}); });
el.innerHTML = crumbs.join('');
// breadcrumb click handling // breadcrumb click handling
el.querySelectorAll('.admin-storage-bc').forEach(btn => { el.querySelectorAll('.admin-storage-bc').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -952,289 +974,413 @@ function switchExplorerTab(tab) {
} }
async function loadFolderChildren(folderKey) { async function loadFolderChildren(folderKey) {
currentFolderKey = folderKey || 'root'; currentFolderKey = folderKey || 'root';
const inner = document.getElementById('adminStorageExplorerInner'); const inner = document.getElementById('adminStorageExplorerInner');
if (!inner) return; if (!inner) return;
inner.innerHTML = ` inner.innerHTML = `
<div class="text-muted small"> <div class="text-muted small">
${tf('loading','Loading...')} ${tf('loading','Loading...')}
</div> </div>
`; `;
setBreadcrumb(currentFolderKey); setBreadcrumb(currentFolderKey);
let data; let data;
try { try {
const url = `/api/pro/diskUsageChildren.php?folder=${encodeURIComponent(currentFolderKey)}`; const url = `/api/pro/diskUsageChildren.php?folder=${encodeURIComponent(currentFolderKey)}`;
const res = await fetch(url, { const res = await fetch(url, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
headers: { 'Cache-Control': 'no-store' } headers: { 'Cache-Control': 'no-store' }
}); });
const text = await res.text(); const text = await res.text();
data = JSON.parse(text || '{}'); data = JSON.parse(text || '{}');
} catch (e) { } catch (e) {
console.error('loadFolderChildren error', e); console.error('loadFolderChildren error', e);
inner.innerHTML = `<div class="text-danger small"> inner.innerHTML = `<div class="text-danger small">
${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')} ${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')}
</div>`;
return;
}
if (!data || !data.ok) {
if (data && data.error === 'no_snapshot') {
inner.innerHTML = `<div class="text-warning small">
${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')}
</div>`; </div>`;
return; } else {
} // Special-case: backend missing ProDiskUsage / outdated Pro bundle
let msgKey = 'storage_children_error';
if (!data || !data.ok) { let fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.';
if (data && data.error === 'no_snapshot') {
inner.innerHTML = `<div class="text-warning small"> if (
${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')} data &&
</div>`; data.error === 'internal_error' &&
} else { data.message &&
// Special-case: backend missing ProDiskUsage / outdated Pro bundle /ProDiskUsage/i.test(String(data.message))
let msgKey = 'storage_children_error'; ) {
let fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.'; msgKey = 'storage_pro_bundle_outdated';
fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.';
if (
data &&
data.error === 'internal_error' &&
data.message &&
/ProDiskUsage/i.test(String(data.message))
) {
msgKey = 'storage_pro_bundle_outdated';
fallback = 'Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.';
}
inner.innerHTML = `<div class="text-danger small">
${tf(msgKey, fallback)}
</div>`;
}
return;
} }
const folders = Array.isArray(data.folders) ? data.folders : []; inner.innerHTML = `<div class="text-danger small">
const files = Array.isArray(data.files) ? data.files : []; ${tf(msgKey, fallback)}
</div>`;
const minBytes = folderMinSizeBytes || 0; }
return;
const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes); }
const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes);
const folders = Array.isArray(data.folders) ? data.folders : [];
const rowChunks = []; const files = Array.isArray(data.files) ? data.files : [];
// Folder rows first const minBytes = folderMinSizeBytes || 0;
filteredFolders.forEach(f => {
const label = f.folder === 'root' ? '/' : `/${f.folder}`; const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes);
const pct = f.percentOfTotal || 0; const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes);
const width = Math.max(3, Math.min(100, Math.round(pct)));
// Build a unified list for pagination: { kind: 'folder' | 'file', item }
rowChunks.push(` const entries = [];
<tr filteredFolders.forEach(f => entries.push({ kind: 'folder', item: f }));
class="admin-storage-row admin-storage-row-folder" filteredFiles.forEach(file => entries.push({ kind: 'file', item: file }));
data-type="folder"
data-folder="${f.folder}"> const total = entries.length;
<td class="align-middle"> const pageSize = 100;
<i class="material-icons" style="font-size:16px;vertical-align:middle;color:currentColor;">folder</i> let shown = Math.min(pageSize, total);
<span class="ms-1 align-middle">${label}</span>
</td> function renderPage() {
<td class="align-middle text-nowrap"> const hasRows = total > 0;
${formatBytes(f.bytes || 0)} const slice = hasRows ? entries.slice(0, shown) : [];
</td>
<td class="align-middle text-nowrap"> // Clear container
${pct.toFixed(1)}% inner.innerHTML = '';
</td>
<td class="align-middle text-nowrap"> // Table wrapper
${(f.files || 0).toLocaleString()} const wrapper = document.createElement('div');
</td> wrapper.className = 'table-responsive';
<td class="align-middle text-nowrap"> wrapper.style.maxHeight = '340px';
${f.latest_mtime ? formatDate(f.latest_mtime) : ''} wrapper.style.overflow = 'auto';
</td>
<td class="align-middle" style="width:1%;white-space:nowrap;"> const table = document.createElement('table');
<button table.className = 'table table-sm mb-0';
type="button"
class="btn btn-sm btn-outline-danger admin-storage-delete-folder" // ----- thead -----
title="${tf('delete_folder','Delete folder')}"> const thead = document.createElement('thead');
<i class="material-icons" style="font-size:16px;color:currentColor;">delete</i> const headRow = document.createElement('tr');
</button>
</td> const thName = document.createElement('th');
</tr> thName.textContent = tf('name','Name');
`); headRow.appendChild(thName);
});
const thSize = document.createElement('th');
// Then file rows thSize.textContent = tf('size','Size');
filteredFiles.forEach(file => { headRow.appendChild(thSize);
const pct = file.percentOfTotal || 0;
const width = Math.max(3, Math.min(100, Math.round(pct))); const thPct = document.createElement('th');
const folder = file.folder || currentFolderKey; thPct.textContent = '%';
const displayPath = file.path || (folder === 'root' headRow.appendChild(thPct);
? `/${file.name}`
: `/${folder}/${file.name}`); const thFiles = document.createElement('th');
thFiles.textContent = tf('files','Files');
rowChunks.push(` headRow.appendChild(thFiles);
<tr
class="admin-storage-row admin-storage-row-file" const thMod = document.createElement('th');
data-type="file" thMod.textContent = tf('modified','Modified');
data-folder="${folder}" headRow.appendChild(thMod);
data-name="${file.name}">
<td class="align-middle"> const thActions = document.createElement('th');
<i class="material-icons" style="font-size:16px;vertical-align:middle;color:currentColor;">insert_drive_file</i> thActions.style.width = '1%';
<span class="ms-1 align-middle"><code>${displayPath}</code></span> headRow.appendChild(thActions);
</td>
<td class="align-middle text-nowrap"> thead.appendChild(headRow);
${formatBytes(file.bytes || 0)} table.appendChild(thead);
</td>
<td class="align-middle text-nowrap"> // ----- tbody -----
${pct.toFixed(2)}% const tbody = document.createElement('tbody');
</td>
<td class="align-middle text-nowrap"> if (!hasRows) {
<!-- files count not meaningful here --> const tr = document.createElement('tr');
</td> const td = document.createElement('td');
<td class="align-middle text-nowrap"> td.colSpan = 6;
${file.mtime ? formatDate(file.mtime) : ''} td.className = 'text-muted small';
</td> td.textContent = tf(
<td class="align-middle" style="width:1%;white-space:nowrap;"> 'storage_no_children',
<button 'No matching items in this folder (for current filter).'
type="button" );
class="btn btn-sm btn-outline-danger admin-storage-delete-file" tr.appendChild(td);
title="${tf('delete_file','Delete file')}"> tbody.appendChild(tr);
<i class="material-icons" style="font-size:16px;color:currentColor;">delete</i> } else {
</button> slice.forEach(entry => {
</td> const { kind, item } = entry;
</tr> const tr = document.createElement('tr');
`); tr.classList.add('admin-storage-row');
});
if (kind === 'folder') {
const total = rowChunks.length; // ---- Folder row ----
const pageSize = 100; tr.classList.add('admin-storage-row-folder');
let shown = Math.min(pageSize, total); tr.dataset.type = 'folder';
tr.dataset.folder = item.folder;
function renderPage() {
const hasRows = total > 0; const label = item.folder === 'root' ? '/' : `/${item.folder}`;
const visibleRows = hasRows const pct = item.percentOfTotal || 0;
? rowChunks.slice(0, shown).join('')
: `<tr><td colspan="6" class="text-muted small"> // Name cell
${tf('storage_no_children','No matching items in this folder (for current filter).')} const tdName = document.createElement('td');
</td></tr>`; tdName.className = 'align-middle';
const footer = hasRows && shown < total const icon = document.createElement('i');
? `<div class="d-flex justify-content-between align-items-center mt-1 small"> icon.className = 'material-icons';
<span>${tf('storage_showing','Showing')} ${shown} ${tf('of','of')} ${total}</span> icon.style.fontSize = '16px';
<button type="button" class="btn btn-sm btn-outline-secondary" id="adminStorageMoreFolder"> icon.style.verticalAlign = 'middle';
${tf('storage_load_more','Load more')} icon.style.color = 'currentColor';
</button> icon.textContent = 'folder';
</div>`
: hasRows const span = document.createElement('span');
? `<div class="mt-1 small text-muted text-end pe-2"> span.className = 'ms-1 align-middle';
${tf('storage_showing_all','Showing all')} ${total} ${tf('items','items')}. span.textContent = label;
</div>`
: ''; tdName.appendChild(icon);
tdName.appendChild(span);
inner.innerHTML = ` tr.appendChild(tdName);
<div class="table-responsive" style="max-height:340px;overflow:auto;">
<table class="table table-sm mb-0"> // Size
<thead> const tdSize = document.createElement('td');
<tr> tdSize.className = 'align-middle text-nowrap';
<th>${tf('name','Name')}</th> tdSize.textContent = formatBytes(item.bytes || 0);
<th>${tf('size','Size')}</th> tr.appendChild(tdSize);
<th>%</th>
<th>${tf('files','Files')}</th> // Percent
<th>${tf('modified','Modified')}</th> const tdPct = document.createElement('td');
<th style="width:1%;"></th> tdPct.className = 'align-middle text-nowrap';
</tr> tdPct.textContent = `${pct.toFixed(1)}%`;
</thead> tr.appendChild(tdPct);
<tbody>
${visibleRows} // Files count
</tbody> const tdFiles = document.createElement('td');
</table> tdFiles.className = 'align-middle text-nowrap';
</div> tdFiles.textContent = (item.files || 0).toLocaleString();
${footer} tr.appendChild(tdFiles);
`;
// Modified
// Wire folder row clicks (drilldown) const tdMod = document.createElement('td');
inner.querySelectorAll('.admin-storage-row-folder').forEach(row => { tdMod.className = 'align-middle text-nowrap';
row.addEventListener('click', e => { tdMod.textContent = item.latest_mtime ? formatDate(item.latest_mtime) : '';
if (e.target.closest('.admin-storage-delete-folder')) return; tr.appendChild(tdMod);
const folder = row.getAttribute('data-folder') || 'root';
currentFolderKey = folder; // Actions
loadFolderChildren(folder); const tdActions = document.createElement('td');
}); tdActions.className = 'align-middle';
}); tdActions.style.width = '1%';
tdActions.style.whiteSpace = 'nowrap';
// Wire delete folder/file
inner.querySelectorAll('.admin-storage-delete-folder').forEach(btn => { const btn = document.createElement('button');
btn.addEventListener('click', async e => { btn.type = 'button';
e.stopPropagation(); btn.className = 'btn btn-sm btn-outline-danger admin-storage-delete-folder';
const row = btn.closest('tr'); btn.title = tf('delete_folder','Delete folder');
if (!row) return;
const folder = row.getAttribute('data-folder') || ''; const delIcon = document.createElement('i');
if (!folder) return; delIcon.className = 'material-icons';
delIcon.style.fontSize = '16px';
const label = folder === 'root' ? '/' : `/${folder}`; delIcon.style.color = 'currentColor';
delIcon.textContent = 'delete';
if (deepDeleteEnabled) {
// SUPER DANGEROUS: deep delete entire subtree btn.appendChild(delIcon);
const ok = await showConfirmDialog({ tdActions.appendChild(btn);
title: tf('storage_confirm_deep_delete_folder_title', 'Deep delete folder'), tr.appendChild(tdActions);
message: tf(
'storage_confirm_deep_delete_folder_msg', // Folder row click: drilldown
`Permanently delete folder ${label} and ALL files and subfolders under it? This cannot be undone.` tr.addEventListener('click', e => {
), if (e.target.closest('.admin-storage-delete-folder')) return;
confirmLabel: tf('deep_delete_folder','Deep delete') const folder = tr.getAttribute('data-folder') || 'root';
}); currentFolderKey = folder;
if (!ok) return; loadFolderChildren(folder);
await deleteFolderFromInspector(folder, row, { deep: true });
} else {
// Safe mode: only empty folders, same as existing UI
const ok = await showConfirmDialog({
title: tf('storage_confirm_delete_folder_title', 'Delete folder'),
message: tf(
'storage_confirm_delete_folder_msg',
`Delete folder ${label}? If the folder is not empty, deletion will fail.`
),
confirmLabel: tf('delete_folder','Delete folder')
});
if (!ok) return;
await deleteFolderFromInspector(folder, row, { deep: false });
}
});
});
inner.querySelectorAll('.admin-storage-delete-file').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const row = btn.closest('tr');
if (!row) return;
const folder = row.getAttribute('data-folder') || currentFolderKey || 'root';
const name = row.getAttribute('data-name') || '';
if (!name) return;
const display = folder === 'root' ? `/${name}` : `/${folder}/${name}`;
const ok = await showConfirmDialog({
title: tf('storage_confirm_delete_file_title', 'Permanently delete file'),
message: tf(
'storage_confirm_delete_file_msg',
`Permanently delete file ${display}? This bypasses Trash and cannot be undone.`
),
confirmLabel: tf('delete_file','Delete file')
}); });
if (!ok) return;
// Delete folder click
await deleteFileFromInspectorPermanent(folder, name, row); btn.addEventListener('click', async e => {
}); e.stopPropagation();
const folder = tr.getAttribute('data-folder') || '';
if (!folder) return;
const labelDisp = folder === 'root' ? '/' : `/${folder}`;
if (deepDeleteEnabled) {
// SUPER DANGEROUS: deep delete entire subtree
const ok = await showConfirmDialog({
title: tf('storage_confirm_deep_delete_folder_title', 'Deep delete folder'),
message: tf(
'storage_confirm_deep_delete_folder_msg',
`Permanently delete folder ${labelDisp} and ALL files and subfolders under it? This cannot be undone.`
),
confirmLabel: tf('deep_delete_folder','Deep delete')
});
if (!ok) return;
await deleteFolderFromInspector(folder, tr, { deep: true });
} else {
// Safe mode: only empty folders, same as existing UI
const ok = await showConfirmDialog({
title: tf('storage_confirm_delete_folder_title', 'Delete folder'),
message: tf(
'storage_confirm_delete_folder_msg',
`Delete folder ${labelDisp}? If the folder is not empty, deletion will fail.`
),
confirmLabel: tf('delete_folder','Delete folder')
});
if (!ok) return;
await deleteFolderFromInspector(folder, tr, { deep: false });
}
});
} else {
// ---- File row ----
tr.classList.add('admin-storage-row-file');
tr.dataset.type = 'file';
const folder = item.folder || currentFolderKey;
const displayPath = item.path || (folder === 'root'
? `/${item.name}`
: `/${folder}/${item.name}`);
const pct = item.percentOfTotal || 0;
tr.dataset.folder = folder;
tr.dataset.name = item.name;
// Name cell
const tdName = document.createElement('td');
tdName.className = 'align-middle';
const icon = document.createElement('i');
icon.className = 'material-icons';
icon.style.fontSize = '16px';
icon.style.verticalAlign = 'middle';
icon.style.color = 'currentColor';
icon.textContent = 'insert_drive_file';
const span = document.createElement('span');
span.className = 'ms-1 align-middle';
const code = document.createElement('code');
code.textContent = displayPath;
span.appendChild(code);
tdName.appendChild(icon);
tdName.appendChild(span);
tr.appendChild(tdName);
// Size
const tdSize = document.createElement('td');
tdSize.className = 'align-middle text-nowrap';
tdSize.textContent = formatBytes(item.bytes || 0);
tr.appendChild(tdSize);
// Percent
const tdPct = document.createElement('td');
tdPct.className = 'align-middle text-nowrap';
tdPct.textContent = `${pct.toFixed(2)}%`;
tr.appendChild(tdPct);
// Files (blank for files)
const tdFiles = document.createElement('td');
tdFiles.className = 'align-middle text-nowrap';
tdFiles.textContent = '';
tr.appendChild(tdFiles);
// Modified
const tdMod = document.createElement('td');
tdMod.className = 'align-middle text-nowrap';
tdMod.textContent = item.mtime ? formatDate(item.mtime) : '';
tr.appendChild(tdMod);
// Actions
const tdActions = document.createElement('td');
tdActions.className = 'align-middle';
tdActions.style.width = '1%';
tdActions.style.whiteSpace = 'nowrap';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-danger admin-storage-delete-file';
btn.title = tf('delete_file','Delete file');
const delIcon = document.createElement('i');
delIcon.className = 'material-icons';
delIcon.style.fontSize = '16px';
delIcon.style.color = 'currentColor';
delIcon.textContent = 'delete';
btn.appendChild(delIcon);
tdActions.appendChild(btn);
tr.appendChild(tdActions);
btn.addEventListener('click', async e => {
e.stopPropagation();
const f = tr.getAttribute('data-folder') || currentFolderKey || 'root';
const name = tr.getAttribute('data-name') || '';
if (!name) return;
const display = f === 'root' ? `/${name}` : `/${f}/${name}`;
const ok = await showConfirmDialog({
title: tf('storage_confirm_delete_file_title', 'Permanently delete file'),
message: tf(
'storage_confirm_delete_file_msg',
`Permanently delete file ${display}? This bypasses Trash and cannot be undone.`
),
confirmLabel: tf('delete_file','Delete file')
});
if (!ok) return;
await deleteFileFromInspectorPermanent(f, name, tr);
});
}
tbody.appendChild(tr);
}); });
}
const moreBtn = inner.querySelector('#adminStorageMoreFolder');
if (moreBtn) { table.appendChild(tbody);
wrapper.appendChild(table);
inner.appendChild(wrapper);
// ----- Footer: showing X of Y / Load more -----
if (hasRows) {
if (shown < total) {
const footer = document.createElement('div');
footer.className = 'd-flex justify-content-between align-items-center mt-1 small';
const span = document.createElement('span');
span.textContent = `${tf('storage_showing','Showing')} ${shown} ${tf('of','of')} ${total}`;
footer.appendChild(span);
const moreBtn = document.createElement('button');
moreBtn.type = 'button';
moreBtn.className = 'btn btn-sm btn-outline-secondary';
moreBtn.id = 'adminStorageMoreFolder';
moreBtn.textContent = tf('storage_load_more','Load more');
moreBtn.addEventListener('click', () => { moreBtn.addEventListener('click', () => {
shown = Math.min(shown + pageSize, total); shown = Math.min(shown + pageSize, total);
renderPage(); renderPage();
}); });
footer.appendChild(moreBtn);
inner.appendChild(footer);
} else {
const footer = document.createElement('div');
footer.className = 'mt-1 small text-muted text-end pe-2';
footer.textContent = `${tf('storage_showing_all','Showing all')} ${total} ${tf('items','items')}.`;
inner.appendChild(footer);
} }
} }
renderPage(); // Sync button styles with deep delete toggle
updateDeleteButtonsForDeepDelete();
} }
renderPage();
}
async function loadTopFiles() { async function loadTopFiles() {
const inner = document.getElementById('adminStorageExplorerInner'); const inner = document.getElementById('adminStorageExplorerInner');
if (!inner) return; if (!inner) return;