diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ddf87f..42a59e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# 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)
release(v2.2.0): add storage explorer + disk usage scanner
diff --git a/README.md b/README.md
index 2e29da2..c36805c 100644
--- a/README.md
+++ b/README.md
@@ -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.
- 🧩 **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.
+- 📊 **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.
- 🔑 **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)
diff --git a/public/js/adminStorage.js b/public/js/adminStorage.js
index acc8626..21b7515 100644
--- a/public/js/adminStorage.js
+++ b/public/js/adminStorage.js
@@ -868,45 +868,67 @@ function setBreadcrumb(folderKey) {
const el = document.getElementById('adminStorageBreadcrumb');
if (!el) return;
+ // Clear existing content safely
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+
let parts = [];
- if (!folderKey || folderKey === 'root') {
- parts = [];
- } else {
+ if (folderKey && folderKey !== 'root') {
const clean = folderKey.replace(/^\/+|\/+$/g, '');
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
- crumbs.push(`
-
- home
- ${tf('storage_root_label','root')}
-
- `);
+ // Root crumb
+ const rootBtn = document.createElement('button');
+ rootBtn.type = 'button';
+ rootBtn.className = 'btn btn-link btn-sm p-0 admin-storage-bc';
+ rootBtn.dataset.folder = 'root';
+
+ 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 = '';
parts.forEach((p, idx) => {
+ appendSeparator();
accum = accum ? (accum + '/' + p) : p;
const isLast = idx === parts.length - 1;
+
if (isLast) {
- crumbs.push(`
- /
- ${p}
- `);
+ const span = document.createElement('span');
+ span.className = 'fw-semibold';
+ span.textContent = p;
+ el.appendChild(span);
} else {
- crumbs.push(`
- /
-
- ${p}
-
- `);
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'btn btn-link btn-sm p-0 admin-storage-bc';
+ btn.dataset.folder = accum;
+ btn.textContent = p;
+ el.appendChild(btn);
}
});
- el.innerHTML = crumbs.join('');
-
// breadcrumb click handling
el.querySelectorAll('.admin-storage-bc').forEach(btn => {
btn.addEventListener('click', () => {
@@ -952,289 +974,413 @@ function switchExplorerTab(tab) {
}
async function loadFolderChildren(folderKey) {
- currentFolderKey = folderKey || 'root';
-
- const inner = document.getElementById('adminStorageExplorerInner');
- if (!inner) return;
-
- inner.innerHTML = `
-
- ${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')}
+ currentFolderKey = folderKey || 'root';
+
+ const inner = document.getElementById('adminStorageExplorerInner');
+ if (!inner) return;
+
+ inner.innerHTML = `
+
+ ${tf('loading','Loading...')}
+
+ `;
+
+ setBreadcrumb(currentFolderKey);
+
+ let data;
+ try {
+ const url = `/api/pro/diskUsageChildren.php?folder=${encodeURIComponent(currentFolderKey)}`;
+ const res = await fetch(url, {
+ credentials: 'include',
+ cache: 'no-store',
+ headers: { 'Cache-Control': 'no-store' }
+ });
+ const text = await res.text();
+ data = JSON.parse(text || '{}');
+ } catch (e) {
+ console.error('loadFolderChildren error', e);
+ inner.innerHTML = `
+ ${tf('storage_children_error','Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.')}
+
`;
+ return;
+ }
+
+ if (!data || !data.ok) {
+ if (data && data.error === 'no_snapshot') {
+ inner.innerHTML = `
+ ${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')}
`;
- return;
- }
-
- if (!data || !data.ok) {
- if (data && data.error === 'no_snapshot') {
- inner.innerHTML = `
- ${tf('storage_no_snapshot','No disk usage snapshot found. Run the disk usage scan first.')}
-
`;
- } else {
- // Special-case: backend missing ProDiskUsage / outdated Pro bundle
- let msgKey = 'storage_children_error';
- let 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 = `
- ${tf(msgKey, fallback)}
-
`;
- }
- return;
+ } else {
+ // Special-case: backend missing ProDiskUsage / outdated Pro bundle
+ let msgKey = 'storage_children_error';
+ let 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.';
}
-
- const folders = Array.isArray(data.folders) ? data.folders : [];
- const files = Array.isArray(data.files) ? data.files : [];
-
- const minBytes = folderMinSizeBytes || 0;
-
- const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes);
- const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes);
-
- const rowChunks = [];
-
- // Folder rows first
- filteredFolders.forEach(f => {
- const label = f.folder === 'root' ? '/' : `/${f.folder}`;
- const pct = f.percentOfTotal || 0;
- const width = Math.max(3, Math.min(100, Math.round(pct)));
-
- rowChunks.push(`
-
-
- folder
- ${label}
-
-
- ${formatBytes(f.bytes || 0)}
-
-
- ${pct.toFixed(1)}%
-
-
- ${(f.files || 0).toLocaleString()}
-
-
- ${f.latest_mtime ? formatDate(f.latest_mtime) : ''}
-
-
-
- delete
-
-
-
- `);
- });
-
- // Then file rows
- filteredFiles.forEach(file => {
- const pct = file.percentOfTotal || 0;
- const width = Math.max(3, Math.min(100, Math.round(pct)));
- const folder = file.folder || currentFolderKey;
- const displayPath = file.path || (folder === 'root'
- ? `/${file.name}`
- : `/${folder}/${file.name}`);
-
- rowChunks.push(`
-
-
- insert_drive_file
- ${displayPath}
-
-
- ${formatBytes(file.bytes || 0)}
-
-
- ${pct.toFixed(2)}%
-
-
-
-
-
- ${file.mtime ? formatDate(file.mtime) : ''}
-
-
-
- delete
-
-
-
- `);
- });
-
- const total = rowChunks.length;
- const pageSize = 100;
- let shown = Math.min(pageSize, total);
-
- function renderPage() {
- const hasRows = total > 0;
- const visibleRows = hasRows
- ? rowChunks.slice(0, shown).join('')
- : `
- ${tf('storage_no_children','No matching items in this folder (for current filter).')}
- `;
-
- const footer = hasRows && shown < total
- ? `
- ${tf('storage_showing','Showing')} ${shown} ${tf('of','of')} ${total}
-
- ${tf('storage_load_more','Load more')}
-
-
`
- : hasRows
- ? `
- ${tf('storage_showing_all','Showing all')} ${total} ${tf('items','items')}.
-
`
- : '';
-
- inner.innerHTML = `
-
-
-
-
- ${tf('name','Name')}
- ${tf('size','Size')}
- %
- ${tf('files','Files')}
- ${tf('modified','Modified')}
-
-
-
-
- ${visibleRows}
-
-
-
- ${footer}
- `;
-
- // Wire folder row clicks (drilldown)
- inner.querySelectorAll('.admin-storage-row-folder').forEach(row => {
- row.addEventListener('click', e => {
- if (e.target.closest('.admin-storage-delete-folder')) return;
- const folder = row.getAttribute('data-folder') || 'root';
- currentFolderKey = folder;
- loadFolderChildren(folder);
- });
- });
-
- // Wire delete folder/file
- inner.querySelectorAll('.admin-storage-delete-folder').forEach(btn => {
- btn.addEventListener('click', async e => {
- e.stopPropagation();
- const row = btn.closest('tr');
- if (!row) return;
- const folder = row.getAttribute('data-folder') || '';
- if (!folder) return;
-
- const label = 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 ${label} and ALL files and subfolders under it? This cannot be undone.`
- ),
- confirmLabel: tf('deep_delete_folder','Deep delete')
- });
- if (!ok) return;
-
- 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')
+
+ inner.innerHTML = `
+ ${tf(msgKey, fallback)}
+
`;
+ }
+ return;
+ }
+
+ const folders = Array.isArray(data.folders) ? data.folders : [];
+ const files = Array.isArray(data.files) ? data.files : [];
+
+ const minBytes = folderMinSizeBytes || 0;
+
+ const filteredFolders = folders.filter(f => Number(f.bytes || 0) >= minBytes);
+ const filteredFiles = files.filter(f => Number(f.bytes || 0) >= minBytes);
+
+ // Build a unified list for pagination: { kind: 'folder' | 'file', item }
+ const entries = [];
+ filteredFolders.forEach(f => entries.push({ kind: 'folder', item: f }));
+ filteredFiles.forEach(file => entries.push({ kind: 'file', item: file }));
+
+ const total = entries.length;
+ const pageSize = 100;
+ let shown = Math.min(pageSize, total);
+
+ function renderPage() {
+ const hasRows = total > 0;
+ const slice = hasRows ? entries.slice(0, shown) : [];
+
+ // Clear container
+ inner.innerHTML = '';
+
+ // Table wrapper
+ const wrapper = document.createElement('div');
+ wrapper.className = 'table-responsive';
+ wrapper.style.maxHeight = '340px';
+ wrapper.style.overflow = 'auto';
+
+ const table = document.createElement('table');
+ table.className = 'table table-sm mb-0';
+
+ // ----- thead -----
+ const thead = document.createElement('thead');
+ const headRow = document.createElement('tr');
+
+ const thName = document.createElement('th');
+ thName.textContent = tf('name','Name');
+ headRow.appendChild(thName);
+
+ const thSize = document.createElement('th');
+ thSize.textContent = tf('size','Size');
+ headRow.appendChild(thSize);
+
+ const thPct = document.createElement('th');
+ thPct.textContent = '%';
+ headRow.appendChild(thPct);
+
+ const thFiles = document.createElement('th');
+ thFiles.textContent = tf('files','Files');
+ headRow.appendChild(thFiles);
+
+ const thMod = document.createElement('th');
+ thMod.textContent = tf('modified','Modified');
+ headRow.appendChild(thMod);
+
+ const thActions = document.createElement('th');
+ thActions.style.width = '1%';
+ headRow.appendChild(thActions);
+
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ // ----- tbody -----
+ const tbody = document.createElement('tbody');
+
+ if (!hasRows) {
+ const tr = document.createElement('tr');
+ const td = document.createElement('td');
+ td.colSpan = 6;
+ td.className = 'text-muted small';
+ td.textContent = tf(
+ 'storage_no_children',
+ 'No matching items in this folder (for current filter).'
+ );
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ } else {
+ slice.forEach(entry => {
+ const { kind, item } = entry;
+ const tr = document.createElement('tr');
+ tr.classList.add('admin-storage-row');
+
+ if (kind === 'folder') {
+ // ---- Folder row ----
+ tr.classList.add('admin-storage-row-folder');
+ tr.dataset.type = 'folder';
+ tr.dataset.folder = item.folder;
+
+ const label = item.folder === 'root' ? '/' : `/${item.folder}`;
+ const pct = item.percentOfTotal || 0;
+
+ // 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 = 'folder';
+
+ const span = document.createElement('span');
+ span.className = 'ms-1 align-middle';
+ span.textContent = label;
+
+ 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(1)}%`;
+ tr.appendChild(tdPct);
+
+ // Files count
+ const tdFiles = document.createElement('td');
+ tdFiles.className = 'align-middle text-nowrap';
+ tdFiles.textContent = (item.files || 0).toLocaleString();
+ tr.appendChild(tdFiles);
+
+ // Modified
+ const tdMod = document.createElement('td');
+ tdMod.className = 'align-middle text-nowrap';
+ tdMod.textContent = item.latest_mtime ? formatDate(item.latest_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-folder';
+ btn.title = tf('delete_folder','Delete folder');
+
+ 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);
+
+ // Folder row click: drilldown
+ tr.addEventListener('click', e => {
+ if (e.target.closest('.admin-storage-delete-folder')) return;
+ const folder = tr.getAttribute('data-folder') || 'root';
+ currentFolderKey = folder;
+ loadFolderChildren(folder);
});
- if (!ok) return;
-
- await deleteFileFromInspectorPermanent(folder, name, row);
- });
+
+ // Delete folder click
+ 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', () => {
shown = Math.min(shown + pageSize, total);
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() {
const inner = document.getElementById('adminStorageExplorerInner');
if (!inner) return;