release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening

This commit is contained in:
Ryan
2025-11-09 01:45:39 -05:00
committed by GitHub
parent 4c849b1dc3
commit abd3dad5a5
7 changed files with 596 additions and 109 deletions

View File

@@ -86,7 +86,7 @@ export function getParentFolder(folder) {
Breadcrumb Functions
----------------------*/
function setControlEnabled(el, enabled) {
function setControlEnabled(el, enabled) {
if (!el) return;
if ('disabled' in el) el.disabled = !enabled;
el.classList.toggle('disabled', !enabled);
@@ -101,7 +101,7 @@ async function applyFolderCapabilities(folder) {
const caps = await res.json();
window.currentFolderCaps = caps;
const isRoot = (folder === 'root');
const isRoot = (folder === 'root');
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
@@ -143,7 +143,7 @@ function breadcrumbClickHandler(e) {
updateBreadcrumbTitle(folder);
applyFolderCapabilities(folder);
expandTreePath(folder);
expandTreePath(folder, { persist: false, includeLeaf: false });
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected");
@@ -184,7 +184,7 @@ function breadcrumbDropHandler(e) {
/* FOLDER MOVE FALLBACK */
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
if (plain) {
const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") {
@@ -208,7 +208,7 @@ function breadcrumbDropHandler(e) {
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
@@ -268,8 +268,8 @@ async function checkUserFolderPermission() {
const isFolderOnly =
!!(permissionsData &&
permissionsData[username] &&
permissionsData[username].folderOnly);
permissionsData[username] &&
permissionsData[username].folderOnly);
window.userFolderOnly = isFolderOnly;
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
@@ -287,59 +287,217 @@ async function checkUserFolderPermission() {
}
}
// ---------------- SVG icons + icon helpers ----------------
const _nonEmptyCache = new Map();
/** Return inline SVG string for either an empty folder or folder-with-paper */
/* ----------------------
Folder icon (SVG + fetch + cache)
----------------------*/
// Crisp emoji-like folder (empty / with paper)
function folderSVG(kind = 'empty') {
return `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<!-- Angled back body -->
<path class="folder-back"
d="M3 7.4h7.6l1.6 1.8H20.3c1.1 0 2 .9 2 2v7.6c0 1.1-.9 2-2 2H5
c-1.1 0-2-.9-2-2V9.4c0-1.1.9-2 2-2z"/>
${kind === 'paper'
? `
<!-- Paper raised so it peeks above the lip -->
<rect class="paper" x="6.1" y="5.7" width="11.8" height="10.8" rx="1.2"/>
<!-- Bigger fold -->
<path class="paper-fold" d="M18.0 5.7h-3.2l3.2 3.2z"/>
<!-- Content lines -->
<path class="paper-line" d="M7.7 8.2h8.3"/>
<path class="paper-line" d="M7.7 9.8h7.2"/>
<path class="paper-line" d="M7.7 11.3h6.0"/>
`
: ''
}
<!-- Front lip (angled) -->
<path class="folder-front"
d="M2.3 10.1H10.9l2.0-2.1h7.4c.94 0 1.7.76 1.7 1.7v7.3c0 .94-.76 1.7-1.7 1.7H4
c-.94 0-1.7-.76-1.7-1.7v-6.9z"/>
<!-- Subtle highlight along the lip to add depth -->
<path class="lip-highlight"
d="M3.3 10.2H11.2l1.7-1.8h7.0"
/>
</svg>`;
}
const _folderCountCache = new Map();
const _inflightCounts = new Map();
// --- tiny fetch helper with timeout
function fetchJSONWithTimeout(url, ms = 3000) {
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), ms);
return fetch(url, { credentials: 'include', signal: ctrl.signal })
.then(r => r.ok ? r.json() : { folders: 0, files: 0 })
.catch(() => ({ folders: 0, files: 0 }))
.finally(() => clearTimeout(tid));
}
// --- simple concurrency limiter (prevents 100 simultaneous requests)
const MAX_CONCURRENT_COUNT_REQS = 6;
let _activeCountReqs = 0;
const _countReqQueue = [];
function _runCount(url) {
return new Promise(resolve => {
const start = () => {
_activeCountReqs++;
fetchJSONWithTimeout(url, 2500)
.then(resolve)
.finally(() => {
_activeCountReqs--;
const next = _countReqQueue.shift();
if (next) next();
});
};
if (_activeCountReqs < MAX_CONCURRENT_COUNT_REQS) start();
else _countReqQueue.push(start);
});
}
async function fetchFolderCounts(folder) {
if (_folderCountCache.has(folder)) return _folderCountCache.get(folder);
if (_inflightCounts.has(folder)) return _inflightCounts.get(folder);
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`;
const p = _runCount(url).then(data => {
const result = {
folders: Number(data?.folders || 0),
files: Number(data?.files || 0),
};
_folderCountCache.set(folder, result);
_inflightCounts.delete(folder);
return result;
});
_inflightCounts.set(folder, p);
return p;
}
function setFolderIconForOption(optEl, kind) {
const iconEl = optEl.querySelector('.folder-icon');
if (!iconEl) return;
iconEl.dataset.kind = kind;
iconEl.innerHTML = folderSVG(kind);
}
function ensureFolderIcon(folder) {
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
if (!opt) return;
// Set a neutral default first so layout is stable
setFolderIconForOption(opt, 'empty');
fetchFolderCounts(folder).then(({ folders, files }) => {
setFolderIconForOption(opt, (folders + files) > 0 ? 'paper' : 'empty');
});
}
/** Set a folder rows icon to 'empty' or 'paper' */
function setFolderIcon(folderPath, kind) {
const iconEl = document.querySelector(`.folder-option[data-folder="${folderPath}"] .folder-icon`);
if (!iconEl) return;
if (iconEl.dataset.icon === kind) return;
iconEl.dataset.icon = kind;
iconEl.innerHTML = folderSVG(kind);
}
/** Fast local heuristic: mark 'paper' if we can see any subfolders under this LI */
function markNonEmptyIfHasChildren(folderPath) {
const option = document.querySelector(`.folder-option[data-folder="${folderPath}"]`);
if (!option) return false;
const li = option.closest('li[role="treeitem"]');
const childUL = li ? li.querySelector(':scope > ul') : null;
const hasChildNodes = !!(childUL && childUL.querySelector('li'));
if (hasChildNodes) { setFolderIcon(folderPath, 'paper'); _nonEmptyCache.set(folderPath, true); }
return hasChildNodes;
}
/** ACL-aware check for files: call a tiny stats endpoint (see part C) */
async function fetchFolderNonEmptyACL(folderPath) {
if (_nonEmptyCache.has(folderPath)) return _nonEmptyCache.get(folderPath);
const { folders, files } = await fetchFolderCounts(folderPath);
const nonEmpty = (folders + files) > 0;
_nonEmptyCache.set(folderPath, nonEmpty);
return nonEmpty;
}
/* ----------------------
DOM Building Functions for Folder Tree
----------------------*/
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
const state = loadFolderTreeState();
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}" role="group">`;
for (const folder in tree) {
const name = folder.toLowerCase();
if (name === "trash" || name === "profile_pics") continue;
const fullPath = parentPath ? parentPath + "/" + folder : folder;
const hasChildren = Object.keys(tree[folder]).length > 0;
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
html += `<li class="folder-item">`;
const isOpen = displayState !== 'none';
html += `<li class="folder-item" role="treeitem" aria-expanded="${hasChildren ? String(isOpen) : 'false'}">`;
html += `<div class="folder-row">`;
if (hasChildren) {
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']';
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
html += `<button type="button" class="folder-toggle" aria-label="${isOpen ? 'Collapse' : 'Expand'}" data-folder="${fullPath}"></button>`;
} else {
html += `<span class="folder-indent-placeholder"></span>`;
}
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
if (hasChildren) {
html += renderFolderTree(tree[folder], fullPath, displayState);
html += `<span class="folder-spacer" aria-hidden="true"></span>`;
}
html += `
<span class="folder-option" draggable="true" data-folder="${fullPath}">
<span class="folder-icon" aria-hidden="true" data-icon="${hasChildren ? 'paper' : 'empty'}">
${folderSVG(hasChildren ? 'paper' : 'empty')}
</span>
<span class="folder-label">${escapeHTML(folder)}</span>
</span>
`;
html += `</div>`; // /.folder-row
if (hasChildren) html += renderFolderTree(tree[folder], fullPath, displayState);
html += `</li>`;
}
html += `</ul>`;
return html;
}
function expandTreePath(path) {
const parts = path.split("/");
let cumulative = "";
parts.forEach((part, index) => {
cumulative = index === 0 ? part : cumulative + "/" + part;
const option = document.querySelector(`.folder-option[data-folder="${cumulative}"]`);
if (option) {
const li = option.parentNode;
const nestedUl = li.querySelector("ul");
if (nestedUl && (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded"))) {
nestedUl.classList.remove("collapsed");
nestedUl.classList.add("expanded");
const toggle = li.querySelector(".folder-toggle");
if (toggle) {
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
const state = loadFolderTreeState();
state[cumulative] = "block";
saveFolderTreeState(state);
}
}
}
// replace your current expandTreePath with this version
function expandTreePath(path, opts = {}) {
const { force = false } = opts;
const state = loadFolderTreeState();
const parts = (path || '').split('/').filter(Boolean);
let cumulative = '';
parts.forEach((part, i) => {
cumulative = i === 0 ? part : `${cumulative}/${part}`;
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
if (!option) return;
const li = option.closest('li[role="treeitem"]');
const nestedUl = li ? li.querySelector(':scope > ul') : null;
if (!nestedUl) return;
// Only expand if caller forces it OR saved state says "block"
const shouldExpand = force || state[cumulative] === 'block';
nestedUl.classList.toggle('expanded', shouldExpand);
nestedUl.classList.toggle('collapsed', !shouldExpand);
li.setAttribute('aria-expanded', String(!!shouldExpand));
});
}
/* ----------------------
Drag & Drop Support for Folder Tree Nodes
----------------------*/
@@ -360,15 +518,15 @@ function folderDropHandler(event) {
try {
const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr);
}
catch (e) {
}
catch (e) {
console.error("Invalid drag data", e);
return;
}
/* FOLDER MOVE FALLBACK */
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
if (plain) {
const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") {
@@ -392,7 +550,7 @@ function folderDropHandler(event) {
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
@@ -442,22 +600,29 @@ function folderDropHandler(event) {
// Safe breadcrumb DOM builder
function renderBreadcrumbFragment(folderPath) {
const frag = document.createDocumentFragment();
const parts = folderPath.split("/");
let acc = "";
parts.forEach((part, idx) => {
acc = idx === 0 ? part : acc + "/" + part;
// Defensive normalize
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
const crumbs = path.split('/').filter(s => s !== ''); // no empty segments
const span = document.createElement("span");
span.classList.add("breadcrumb-link");
let acc = '';
for (let i = 0; i < crumbs.length; i++) {
const part = crumbs[i];
acc = (i === 0) ? part : (acc + '/' + part);
const span = document.createElement('span');
span.className = 'breadcrumb-link';
span.dataset.folder = acc;
span.textContent = part;
frag.appendChild(span);
if (idx < parts.length - 1) {
frag.appendChild(document.createTextNode(" / "));
if (i < crumbs.length - 1) {
const sep = document.createElement('span');
sep.className = 'file-breadcrumb-sep';
sep.textContent = '';
frag.appendChild(sep);
}
});
}
return frag;
}
@@ -536,23 +701,61 @@ export async function loadFolderTree(selectedFolder) {
return;
}
let html = `<div id="rootRow" class="root-row">
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
</div>`;
const state0 = loadFolderTreeState();
const rootOpen = state0[effectiveRoot] !== 'none';
let html = `
<div id="rootRow" class="folder-row" role="treeitem" aria-expanded="${String(rootOpen)}">
<button type="button" class="folder-toggle" data-folder="${effectiveRoot}" aria-label="${rootOpen ? 'Collapse' : 'Expand'}"></button>
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">
<span class="folder-icon" aria-hidden="true"></span>
<span class="folder-label">${effectiveLabel}</span>
</span>
</div>
`;
if (folders.length > 0) {
const tree = buildFolderTree(folders);
html += renderFolderTree(tree, "", "block");
// 👇 pass the root's saved state down to first level
html += renderFolderTree(tree, "", rootOpen ? "block" : "none");
}
container.innerHTML = html;
const st = loadFolderTreeState();
const rootUl = container.querySelector('#rootRow + ul');
if (rootUl) {
const expanded = (st[effectiveRoot] ?? 'block') === 'block';
rootUl.classList.toggle('expanded', expanded);
rootUl.classList.toggle('collapsed', !expanded);
const rr = container.querySelector('#rootRow');
if (rr) rr.setAttribute('aria-expanded', String(expanded));
}
// Prime icons for everything visible
primeFolderIcons(container);
function primeFolderIcons(scopeEl) {
const opts = scopeEl.querySelectorAll('.folder-option[data-folder]');
opts.forEach(opt => {
const f = opt.getAttribute('data-folder');
// Optional: if there are obvious children in DOM, show 'paper' immediately as a hint
const li = opt.closest('li[role="treeitem"]');
const hasChildren = !!(li && li.querySelector(':scope > ul > li'));
setFolderIconForOption(opt, hasChildren ? 'paper' : 'empty');
// Then confirm with server (files count)
ensureFolderIcon(f);
});
}
// Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => {
const fp = el.getAttribute('data-folder');
markNonEmptyIfHasChildren(fp);
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
ev.dataTransfer.effectAllowed = "move";
});
@@ -569,11 +772,12 @@ export async function loadFolderTree(selectedFolder) {
// Initial breadcrumb + file list
updateBreadcrumbTitle(window.currentFolder);
applyFolderCapabilities(window.currentFolder);
ensureFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
const folderState = loadFolderTreeState();
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
expandTreePath(window.currentFolder);
// Show ancestors so the current selection is visible, but don't persist
if (window.currentFolder && window.currentFolder !== effectiveRoot) {
expandTreePath(window.currentFolder, { persist: false, includeLeaf: false });
}
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
@@ -587,8 +791,8 @@ export async function loadFolderTree(selectedFolder) {
// Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
ev.dataTransfer.effectAllowed = "move";
});
@@ -602,55 +806,48 @@ export async function loadFolderTree(selectedFolder) {
updateBreadcrumbTitle(selected);
applyFolderCapabilities(selected);
ensureFolderIcon(selected);
loadFileList(selected);
});
});
// Root toggle handler
// Root toggle
const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) {
rootToggle.addEventListener("click", function (e) {
e.stopPropagation();
const nestedUl = container.querySelector("#rootRow + ul");
if (nestedUl) {
const state = loadFolderTreeState();
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
nestedUl.classList.remove("collapsed");
nestedUl.classList.add("expanded");
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
state[effectiveRoot] = "block";
} else {
nestedUl.classList.remove("expanded");
nestedUl.classList.add("collapsed");
this.textContent = "[+]";
state[effectiveRoot] = "none";
}
saveFolderTreeState(state);
}
if (!nestedUl) return;
const state = loadFolderTreeState();
const expanded = !(nestedUl.classList.contains("expanded"));
nestedUl.classList.toggle("expanded", expanded);
nestedUl.classList.toggle("collapsed", !expanded);
document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded));
state[effectiveRoot] = expanded ? "block" : "none";
saveFolderTreeState(state);
});
}
// Other folder-toggle handlers
container.querySelectorAll(".folder-toggle").forEach(toggle => {
// Other toggles
container.querySelectorAll("button.folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) {
e.stopPropagation();
const siblingUl = this.parentNode.querySelector("ul");
const li = this.closest('li[role="treeitem"]');
const siblingUl = li ? li.querySelector(':scope > ul') : null;
const folderPath = this.getAttribute("data-folder");
if (!siblingUl) return;
const state = loadFolderTreeState();
if (siblingUl) {
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
siblingUl.classList.remove("collapsed");
siblingUl.classList.add("expanded");
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
state[folderPath] = "block";
} else {
siblingUl.classList.remove("expanded");
siblingUl.classList.add("collapsed");
this.textContent = "[+]";
state[folderPath] = "none";
}
saveFolderTreeState(state);
}
const expanded = !(siblingUl.classList.contains("expanded"));
siblingUl.classList.toggle("expanded", expanded);
siblingUl.classList.toggle("collapsed", !expanded);
li.setAttribute("aria-expanded", String(expanded));
state[folderPath] = expanded ? "block" : "none";
saveFolderTreeState(state);
ensureFolderIcon(folderPath);
});
});
@@ -749,7 +946,7 @@ if (submitRename) {
// === Move Folder Modal helper (shared by button + context menu) ===
function openMoveFolderUI(sourceFolder) {
const modal = document.getElementById('moveFolderModal');
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
// If you right-clicked a different folder than currently selected, use that
@@ -779,7 +976,7 @@ function openMoveFolderUI(sourceFolder) {
targetSel.appendChild(o);
});
})
.catch(()=>{ /* no-op */ });
.catch(() => { /* no-op */ });
}
if (modal) modal.style.display = 'block';
@@ -1073,11 +1270,11 @@ document.addEventListener("DOMContentLoaded", function () {
bindFolderManagerContextMenu();
document.addEventListener("DOMContentLoaded", () => {
const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal');
const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
const cancelBtn = document.getElementById('cancelMoveFolder');
const confirmBtn= document.getElementById('confirmMoveFolder');
const confirmBtn = document.getElementById('confirmMoveFolder');
if (moveBtn) {
moveBtn.addEventListener('click', () => {
@@ -1092,7 +1289,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
if (!targetSel) return;
const destination = targetSel.value;
const source = window.currentFolder;
const source = window.currentFolder;
if (!destination) { showToast('Pick a destination'); return; }
if (destination === source || (destination + '/').startsWith(source + '/')) {
@@ -1108,7 +1305,7 @@ document.addEventListener("DOMContentLoaded", () => {
const data = await safeJson(res);
if (res.ok && data && !data.error) {
showToast('Folder moved');
if (modal) modal.style.display='none';
if (modal) modal.style.display = 'none';
await loadFolderTree();
const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base;