From abd3dad5a588fcaf0820ec054ca4dfed68c5b741 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 9 Nov 2025 01:45:39 -0500 Subject: [PATCH] release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening --- CHANGELOG.md | 40 +++ public/.htaccess | 15 +- public/api/folder/isEmpty.php | 30 ++ public/css/styles.css | 173 +++++++++++- public/js/folderManager.js | 401 ++++++++++++++++++++------- src/controllers/FolderController.php | 7 + src/models/FolderModel.php | 39 +++ 7 files changed, 596 insertions(+), 109 deletions(-) create mode 100644 public/api/folder/isEmpty.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3474b88..aced9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## Changes 11/9/2025 (v1.9.0) + +release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening + +feat(ui): modern folder tree + +- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark +- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment +- Breadcrumb tweaks (› separators), hover/selected polish +- Prime icons locally, then confirm via counts for accurate “empty vs non-empty” + +feat(api): add /api/folder/isEmpty.php via controller/model + +- public/api/folder/isEmpty.php delegates to FolderController::stats() +- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry +- Releases PHP session lock early to avoid parallel-request pileups + +perf: cap concurrent “isEmpty” requests + timeouts + +- Small concurrency limiter + fetch timeouts +- In-memory result & inflight caches for fewer network hits + +fix(state): preserve user expand/collapse choices + +- Respect saved folderTreeState; don’t auto-expand unopened nodes +- Only show ancestors for visibility when navigating (no unwanted persists) + +security: tighten .htaccess while enabling WebDAV + +- Deny direct PHP except /api/*.php, /api.php, and /webdav.php +- AcceptPathInfo On; keep path-aware dotfile denial + +refactor: move count logic to model; thin controller action + +chore(css): add unified “folder tree” block with variables (sizes, gaps, colors) + +Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess + +--- + ## Changes 11/8/2025 (v1.8.13) release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync diff --git a/public/.htaccess b/public/.htaccess index 0a98d29..704b676 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -4,6 +4,9 @@ Options -Indexes -Multiviews DirectoryIndex index.html +# Allow PATH_INFO for routes like /webdav.php/foo/bar +AcceptPathInfo On + # ---------------- Security: dotfiles ---------------- # Block direct access to dotfiles like .env, .gitignore, etc. @@ -24,10 +27,14 @@ RewriteRule - - [L] # Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc. RewriteRule "(^|/)\.(?!well-known/)" - [F] -# 2) Deny direct access to PHP outside /api/ -# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc. -RewriteCond %{REQUEST_URI} !^/api/ -RewriteRule \.php$ - [F] +# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller +# - allow /api/*.php (API endpoints) +# - allow /api.php (ReDoc/spec page) +# - allow /webdav.php (SabreDAV front) +RewriteCond %{REQUEST_URI} !^/api/ [NC] +RewriteCond %{REQUEST_URI} !^/api\.php$ [NC] +RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC] +RewriteRule \.php$ - [F,L] # 3) Never redirect local/dev hosts RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC] diff --git a/public/api/folder/isEmpty.php b/public/api/folder/isEmpty.php new file mode 100644 index 0000000..cd3c27d --- /dev/null +++ b/public/api/folder/isEmpty.php @@ -0,0 +1,30 @@ + $_SESSION['role'] ?? null, + 'admin' => $_SESSION['admin'] ?? null, + 'isAdmin' => $_SESSION['isAdmin'] ?? null, +]; +@session_write_close(); + +// Input +$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'; +$folder = str_replace('\\', '/', trim($folder)); +$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/'); + +// Delegate to controller (model handles ACL + path safety) +$result = FolderController::stats($folder, $user, $perms); + +// Always return a compact JSON object like before +echo json_encode([ + 'folders' => (int)($result['folders'] ?? 0), + 'files' => (int)($result['files'] ?? 0), +]); \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index b302b27..43390a8 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1132,7 +1132,7 @@ body { border-radius: 4px; }.folder-tree { list-style-type: none; - padding-left: 10px; + padding-left: 5px; margin: 0; }.folder-tree.collapsed { display: none; @@ -1149,7 +1149,7 @@ body { text-align: right; }.folder-indent-placeholder { display: inline-block; - width: 30px; + width: 5px; }#folderTreeContainer { display: block; }.folder-option { @@ -1955,4 +1955,171 @@ body { text-overflow: ellipsis; } -#downloadProgressBarOuter { height: 10px; } \ No newline at end of file +#downloadProgressBarOuter { height: 10px; } + +/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */ + +/* Knobs (size, spacing, colors) */ +#folderTreeContainer { + /* Colors (used in BOTH themes) */ + --filr-folder-front: #f6b84e; /* front/lip */ + --filr-folder-back: #ffd36e; /* back body */ + --filr-folder-stroke:#a87312; /* outline */ + --filr-paper-fill: #ffffff; /* paper */ + --filr-paper-stroke: #b2c2db; /* paper edges/lines */ + + /* Size & spacing */ + --row-h: 28px; /* row height */ + --twisty: 24px; /* chevron hit-area size */ + --twisty-gap: -5px; /* gap between chevron and row content */ + --icon-size: 24px; /* 22–26 look good */ + --icon-gap: 6px; /* space between icon and label */ + --indent: 10px; /* subtree indent */ +} + +/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */ +.dark-mode #folderTreeContainer { + --filr-folder-front: #f6b84e; + --filr-folder-back: #ffd36e; + --filr-folder-stroke:#a87312; + --filr-paper-fill: #ffffff; + --filr-paper-stroke: #d0def7; /* brighter so it pops on dark */ +} + +#folderTreeContainer .folder-item { position: static; padding-left: 0; } + +/* visible “row” for each node */ +#folderTreeContainer .folder-row { + position: relative; + display: flex; + align-items: center; + height: var(--row-h); + padding-left: calc(var(--twisty) + var(--twisty-gap)); +} + +/* children indent */ +#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); } + +/* ---------- Chevron toggle (twisty) ---------- */ + +#folderTreeContainer .folder-row > button.folder-toggle { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--twisty); height: var(--twisty); + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid transparent; border-radius: 6px; + background: transparent; cursor: pointer; +} + +#folderTreeContainer .folder-row > button.folder-toggle::before { + content: "▸"; /* closed */ + font-size: calc(var(--twisty) * 0.8); + line-height: 1; +} + +#folderTreeContainer li[role="treeitem"][aria-expanded="true"] + > .folder-row > button.folder-toggle::before { content: "▾"; } + +/* root row (it's a
) */ +#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; } + +#folderTreeContainer .folder-row > button.folder-toggle:hover { + border-color: color-mix(in srgb, #7ab3ff 35%, transparent); +} + +/* spacer for leaves so labels align with parents that have a button */ +#folderTreeContainer .folder-row > .folder-spacer { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--twisty); height: var(--twisty); display: inline-block; +} + +#folderTreeContainer .folder-option { + display: inline-flex; + align-items: center; + height: var(--row-h); + line-height: 1.2; /* avoids baseline weirdness */ + padding: 0 8px; + border-radius: 8px; + user-select: none; + white-space: nowrap; + max-width: 100%; + gap: var(--icon-gap); +} + +#folderTreeContainer .folder-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transform: translateY(0.5px); /* tiny optical nudge for text */ +} + +/* ---------- Icon box (size & alignment) ---------- */ + +#folderTreeContainer .folder-icon { + flex: 0 0 var(--icon-size); + width: var(--icon-size); + height: var(--icon-size); + display: inline-flex; + align-items: center; + justify-content: center; + transform: translateY(0.5px); /* tiny optical nudge for SVG */ +} + +#folderTreeContainer .folder-icon svg { + width: 100%; + height: 100%; + display: block; + shape-rendering: geometricPrecision; +} + +/* ---------- Crisp colors & strokes for the SVG parts ---------- */ + +#folderTreeContainer .folder-icon .folder-front, +#folderTreeContainer .folder-icon .folder-back { + fill: currentColor; + stroke: var(--filr-folder-stroke); + stroke-width: 1.1; + vector-effect: non-scaling-stroke; + paint-order: stroke fill; +} + +#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); } +#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); } + +#folderTreeContainer .folder-icon .paper { + fill: var(--filr-paper-fill); + stroke: var(--filr-paper-stroke); + stroke-width: 1.5; /* thick so it reads at 24px */ + paint-order: stroke fill; +} + +#folderTreeContainer .folder-icon .paper-fold { + fill: var(--filr-paper-stroke); +} + +#folderTreeContainer .folder-icon .paper-line { + stroke: var(--filr-paper-stroke); + stroke-width: 1.5; + stroke-linecap: round; + fill: none; + opacity: 0.95; +} + +/* subtle highlight along lip to add depth */ +#folderTreeContainer .folder-icon .lip-highlight { + stroke: #ffffff; + stroke-opacity: .35; + stroke-width: 0.9; + fill: none; + vector-effect: non-scaling-stroke; +} + +/* ---------- Hover / Selected ---------- */ + +#folderTreeContainer .folder-option:hover { + background: rgba(122,179,255,.14); +} + +#folderTreeContainer .folder-option.selected { + background: rgba(122,179,255,.24); + box-shadow: inset 0 0 0 1px rgba(122,179,255,.45); +} diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 4b0a291..2d2bcf7 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -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 ` + `; +} + +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 row’s 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 = `
    `; + let html = `
      `; + 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 += `
    • `; + const isOpen = displayState !== 'none'; + + html += `
    • `; + + html += `
      `; if (hasChildren) { - const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '-' + ']'; - html += `${toggleSymbol}`; + html += ``; } else { - html += ``; - } - html += `${escapeHTML(folder)}`; - if (hasChildren) { - html += renderFolderTree(tree[folder], fullPath, displayState); + html += ``; } + html += ` + + + ${escapeHTML(folder)} + +`; + html += `
      `; // /.folder-row + + if (hasChildren) html += renderFolderTree(tree[folder], fullPath, displayState); html += `
    • `; } + html += `
    `; 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 = "[" + '-' + "]"; - 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 = `
    - [-] - ${effectiveLabel} -
    `; + const state0 = loadFolderTreeState(); + const rootOpen = state0[effectiveRoot] !== 'none'; + + let html = ` +
    + + + + ${effectiveLabel} + +
    +`; + 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 = "[" + '-' + "]"; - 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 = "[" + '-' + "]"; - 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; diff --git a/src/controllers/FolderController.php b/src/controllers/FolderController.php index e6ae309..2a8ef36 100644 --- a/src/controllers/FolderController.php +++ b/src/controllers/FolderController.php @@ -30,6 +30,13 @@ class FolderController return $headers; } + /** Stats for a folder (currently: empty/non-empty via folders/files counts). */ + public static function stats(string $folder, string $user, array $perms): array + { + // Normalize inside model; this is a thin action + return FolderModel::countVisible($folder, $user, $perms); + } + private static function requireCsrf(): void { self::ensureSession(); diff --git a/src/models/FolderModel.php b/src/models/FolderModel.php index 7b05820..1795462 100644 --- a/src/models/FolderModel.php +++ b/src/models/FolderModel.php @@ -10,6 +10,45 @@ class FolderModel * Ownership mapping helpers (stored in META_DIR/folder_owners.json) * ============================================================ */ + public static function countVisible(string $folder, string $user, array $perms): array + { + // Normalize + $folder = ACL::normalizeFolder($folder); + + // ACL gate: if you can’t read, report empty (no leaks) + if (!$user || !ACL::canRead($user, $perms, $folder)) { + return ['folders' => 0, 'files' => 0]; + } + + // Resolve paths under UPLOAD_DIR + $root = rtrim((string)UPLOAD_DIR, '/\\'); + $path = ($folder === 'root') ? $root : ($root . '/' . $folder); + + $realRoot = @realpath($root); + $realPath = @realpath($path); + if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) { + return ['folders' => 0, 'files' => 0]; + } + + // Count quickly, skipping UI-internal dirs + $folders = 0; $files = 0; + try { + foreach (new DirectoryIterator($realPath) as $f) { + if ($f->isDot()) continue; + $name = $f->getFilename(); + if ($name === 'trash' || $name === 'profile_pics') continue; + + if ($f->isDir()) $folders++; else $files++; + if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not + } + } catch (\Throwable $e) { + // Stay quiet + safe + $folders = 0; $files = 0; + } + + return ['folders' => $folders, 'files' => $files]; + } + /** Load the folder → owner map. */ public static function getFolderOwners(): array {