release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user