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

@@ -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; dont 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

View File

@@ -4,6 +4,9 @@
Options -Indexes -Multiviews
DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c>
# 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]

View File

@@ -0,0 +1,30 @@
<?php
// public/api/folder/isEmpty.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_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),
]);

View File

@@ -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; }
#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; /* 2226 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 <div>) */
#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);
}

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;

View File

@@ -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();

View File

@@ -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 cant 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
{