Compare commits

..

4 Commits

9 changed files with 767 additions and 195 deletions

View File

@@ -1,5 +1,60 @@
# Changelog # 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
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
- header dock: show dock whenever icons exist (and on collapse); hide when empty
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
---
## Changes 11/8/2025 (v1.8.12) ## Changes 11/8/2025 (v1.8.12)
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons

View File

@@ -4,6 +4,9 @@
Options -Indexes -Multiviews Options -Indexes -Multiviews
DirectoryIndex index.html DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ---------------- # ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
# Block direct access to dotfiles like .env, .gitignore, etc. # 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. # Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F] RewriteRule "(^|/)\.(?!well-known/)" - [F]
# 2) Deny direct access to PHP outside /api/ # 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc. # - allow /api/*.php (API endpoints)
RewriteCond %{REQUEST_URI} !^/api/ # - allow /api.php (ReDoc/spec page)
RewriteRule \.php$ - [F] # - 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 # 3) Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC] 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

@@ -142,13 +142,13 @@ body {
border-radius: 4px !important; border-radius: 4px !important;
padding: 6px 10px !important; padding: 6px 10px !important;
} }
/* make the drop zone fill leftover space and right-align its own icons */
#headerDropArea.header-drop-zone{
display: flex;
justify-content: flex-end;
min-width: 100px;
} #headerDropArea.header-drop-zone{
display: flex;
justify-content: flex-end; /* buttons to the right */
align-items: center;
min-height: 40px; /* so the label has room */
}
.header-buttons button:hover { .header-buttons button:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
@@ -1132,7 +1132,7 @@ body {
border-radius: 4px; border-radius: 4px;
}.folder-tree { }.folder-tree {
list-style-type: none; list-style-type: none;
padding-left: 10px; padding-left: 5px;
margin: 0; margin: 0;
}.folder-tree.collapsed { }.folder-tree.collapsed {
display: none; display: none;
@@ -1149,7 +1149,7 @@ body {
text-align: right; text-align: right;
}.folder-indent-placeholder { }.folder-indent-placeholder {
display: inline-block; display: inline-block;
width: 30px; width: 5px;
}#folderTreeContainer { }#folderTreeContainer {
display: block; display: block;
}.folder-option { }.folder-option {
@@ -1532,7 +1532,16 @@ body {
.drag-header.active { .drag-header.active {
width: 350px; width: 350px;
height: 750px; height: 750px;
}.main-column { }
/* Fixed-width sidebar (always 350px) */
#sidebarDropArea{
width: 350px;
min-width: 350px;
max-width: 350px;
flex: 0 0 350px;
box-sizing: border-box;
}
.main-column {
flex: 1; flex: 1;
transition: margin-left 0.3s ease; transition: margin-left 0.3s ease;
}#uploadFolderRow { }#uploadFolderRow {
@@ -1600,8 +1609,8 @@ body {
}#sidebarDropArea, }#sidebarDropArea,
#uploadFolderRow { #uploadFolderRow {
background-color: transparent; background-color: transparent;
}
}.dark-mode #sidebarDropArea, .dark-mode #sidebarDropArea,
.dark-mode #uploadFolderRow { .dark-mode #uploadFolderRow {
background-color: transparent; background-color: transparent;
}.dark-mode #sidebarDropArea.highlight, }.dark-mode #sidebarDropArea.highlight,
@@ -1615,8 +1624,6 @@ body {
border: none !important; border: none !important;
}.dragging:focus { }.dragging:focus {
outline: none; outline: none;
}#sidebarDropArea > .card {
margin-bottom: 1rem;
}.card { }.card {
background-color: #fff; background-color: #fff;
color: #000; color: #000;
@@ -1713,8 +1720,9 @@ body {
border: 2px dashed #555; border: 2px dashed #555;
color: #fff; color: #fff;
}.header-drop-zone.drag-active:empty::before { }.header-drop-zone.drag-active:empty::before {
content: "Drop"; content: "Drop Zone";
font-size: 10px; font-size: 10px;
padding-right: 6px;
color: #aaa; color: #aaa;
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */ }/* Disable text selection on rows to prevent accidental copying when shift-clicking */
#fileList tbody tr.clickable-row { #fileList tbody tr.clickable-row {
@@ -1947,4 +1955,171 @@ body {
text-overflow: ellipsis; 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

@@ -41,7 +41,6 @@ function readLayout() {
function writeLayout(layout) { function writeLayout(layout) {
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {})); localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {}));
} }
function setLayoutFor(cardId, zoneId) { function setLayoutFor(cardId, zoneId) {
const layout = readLayout(); const layout = readLayout();
layout[cardId] = zoneId; layout[cardId] = zoneId;
@@ -93,6 +92,7 @@ function removeHeaderIconForCard(card) {
function insertCardInHeader(card) { function insertCardInHeader(card) {
const host = getHeaderDropArea(); const host = getHeaderDropArea();
if (!host) return; if (!host) return;
// Ensure hidden container exists to park real cards while icon-visible. // Ensure hidden container exists to park real cards while icon-visible.
let hidden = $('hiddenCardsContainer'); let hidden = $('hiddenCardsContainer');
if (!hidden) { if (!hidden) {
@@ -110,10 +110,10 @@ function insertCardInHeader(card) {
iconButton.style.border = 'none'; iconButton.style.border = 'none';
iconButton.style.background = 'none'; iconButton.style.background = 'none';
iconButton.style.cursor = 'pointer'; iconButton.style.cursor = 'pointer';
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${card.id === 'uploadCard' ? 'cloud_upload' iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${
: card.id === 'folderManagementCard' ? 'folder' card.id === 'uploadCard' ? 'cloud_upload' :
: 'insert_drive_file' card.id === 'folderManagementCard' ? 'folder' : 'insert_drive_file'
}</i>`; }</i>`;
iconButton.cardElement = card; iconButton.cardElement = card;
card.headerIconButton = iconButton; card.headerIconButton = iconButton;
@@ -150,8 +150,8 @@ function insertCardInHeader(card) {
function showModal() { function showModal() {
ensureModal(); ensureModal();
if (!modal.contains(card)) { if (!modal.contains(card)) {
let hidden = $('hiddenCardsContainer'); const hiddenNow = $('hiddenCardsContainer');
if (hidden && hidden.contains(card)) hidden.removeChild(card); if (hiddenNow && hiddenNow.contains(card)) hiddenNow.removeChild(card);
card.style.width = ''; card.style.width = '';
card.style.minWidth = ''; card.style.minWidth = '';
modal.appendChild(card); modal.appendChild(card);
@@ -163,8 +163,8 @@ function insertCardInHeader(card) {
if (!modal) return; if (!modal) return;
modal.style.visibility = 'hidden'; modal.style.visibility = 'hidden';
modal.style.opacity = '0'; modal.style.opacity = '0';
const hidden = $('hiddenCardsContainer'); const hiddenNow = $('hiddenCardsContainer');
if (hidden && modal.contains(card)) hidden.appendChild(card); if (hiddenNow && modal.contains(card)) hiddenNow.appendChild(card);
} }
function maybeHide() { function maybeHide() {
setTimeout(() => { setTimeout(() => {
@@ -181,6 +181,8 @@ function insertCardInHeader(card) {
}); });
host.appendChild(iconButton); host.appendChild(iconButton);
// make sure the dock is visible when icons exist
showHeaderDockPersistent();
saveHeaderOrder(); saveHeaderOrder();
} }
@@ -227,6 +229,10 @@ function placeCardInZone(card, zoneId, { animate = true } = {}) {
break; break;
} }
} }
updateTopZoneLayout();
updateSidebarVisibility();
updateZonesToggleUI(); // live update when zones change
} }
function currentZoneForCard(card) { function currentZoneForCard(card) {
@@ -234,7 +240,6 @@ function currentZoneForCard(card) {
const pid = card.parentNode.id || ''; const pid = card.parentNode.id || '';
if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER; if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER;
if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid; if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid;
// If card is temporarily in modal (header), treat as header
if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER; if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER;
return pid || null; return pid || null;
} }
@@ -248,44 +253,6 @@ function saveCurrentLayout() {
writeLayout(layout); writeLayout(layout);
} }
function applyUserLayoutOrDefault() {
const layout = readLayout();
const hasAny = Object.keys(layout).length > 0;
// If we have saved user layout, honor it
if (hasAny) {
getCards().forEach(card => {
const targetZone = layout[card.id];
if (!targetZone) return;
// On small screens: if saved zone is the sidebar, temporarily place in top cols
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(card, target, { animate: false });
} else {
placeCardInZone(card, targetZone, { animate: false });
}
});
updateTopZoneLayout();
updateSidebarVisibility();
return;
}
// No saved layout yet: apply defaults
if (!isSmallScreen()) {
// Wide: default both to sidebar (if not already)
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
} else {
// Small: deterministic mapping
getCards().forEach(c => {
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(c, zone, { animate: false });
});
}
updateTopZoneLayout();
updateSidebarVisibility();
saveCurrentLayout(); // initialize baseline so future moves persist
}
// -------------------- responsive stash -------------------- // -------------------- responsive stash --------------------
function stashSidebarCardsBeforeSmall() { function stashSidebarCardsBeforeSmall() {
const sb = getSidebar(); const sb = getSidebar();
@@ -339,21 +306,62 @@ function enforceResponsiveZones() {
__wasSmall = nowSmall; __wasSmall = nowSmall;
updateTopZoneLayout(); updateTopZoneLayout();
updateSidebarVisibility(); updateSidebarVisibility();
updateZonesToggleUI(); // keep icon in sync when responsive flips
}
// -------------------- header dock visibility helpers --------------------
function showHeaderDockPersistent() {
const h = getHeaderDropArea();
if (h) {
h.style.display = 'inline-flex';
h.classList.add('dock-visible');
}
}
function hideHeaderDockPersistent() {
const h = getHeaderDropArea();
if (h) {
h.classList.remove('dock-visible');
if (h.children.length === 0) h.style.display = 'none';
}
} }
// -------------------- zones toggle (collapse to header) -------------------- // -------------------- zones toggle (collapse to header) --------------------
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; } function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
function applyCollapsedBodyClass() {
// helps grid/containers expand the file list area when sidebar is hidden
document.body.classList.toggle('sidebar-hidden', isZonesCollapsed());
const main = document.querySelector('.main-wrapper') || document.querySelector('#main') || document.querySelector('main');
if (main) {
main.style.contain = 'size';
void main.offsetHeight;
setTimeout(() => { main.style.removeProperty('contain'); }, 0);
}
}
function setZonesCollapsed(collapsed) { function setZonesCollapsed(collapsed) {
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0'); localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
if (collapsed) { if (collapsed) {
// Move ALL cards to header icons (transient). Do not overwrite saved layout. // Move ALL cards to header icons (transient) regardless of where they were.
getCards().forEach(insertCardInHeader); getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
const sb = getSidebar();
if (sb) sb.style.display = 'none';
} else { } else {
// Restore the saved user layout. // Restore saved layout + rebuild header icons only for HEADER-assigned cards
applyUserLayoutOrDefault(); applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
} }
updateSidebarVisibility();
updateTopZoneLayout();
ensureZonesToggle(); ensureZonesToggle();
updateZonesToggleUI(); updateZonesToggleUI();
applyCollapsedBodyClass();
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
} }
function getHeaderHost() { function getHeaderHost() {
@@ -379,7 +387,6 @@ function ensureZonesToggle() {
position: 'absolute', position: 'absolute',
top: `${TOGGLE_TOP_PX}px`, top: `${TOGGLE_TOP_PX}px`,
left: `${TOGGLE_LEFT_PX}px`, left: `${TOGGLE_LEFT_PX}px`,
zIndex: '10010',
width: '38px', width: '38px',
height: '38px', height: '38px',
borderRadius: '19px', borderRadius: '19px',
@@ -424,7 +431,7 @@ function updateZonesToggleUI() {
iconEl.style.transition = 'transform 0.2s ease'; iconEl.style.transition = 'transform 0.2s ease';
iconEl.style.display = 'inline-flex'; iconEl.style.display = 'inline-flex';
iconEl.style.alignItems = 'center'; iconEl.style.alignItems = 'center';
// fun rotate if both cards are in top zone // rotate if both cards are in top zone (only when not collapsed)
const tz = getTopZone(); const tz = getTopZone();
const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard'); const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard');
iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)'; iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)';
@@ -486,7 +493,31 @@ function updateTopZoneLayout() {
if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none'; if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none';
} }
// drag visual helpers // --- sidebar placeholder while dragging (only when empty) ---
function ensureSidebarPlaceholder() {
const sb = getSidebar();
if (!sb) return;
if (hasSidebarCards()) return; // only when empty
let ph = sb.querySelector('.sb-dnd-placeholder');
if (!ph) {
ph = document.createElement('div');
ph.className = 'sb-dnd-placeholder';
Object.assign(ph.style, {
height: '340px',
width: '100%',
visibility: 'hidden'
});
sb.appendChild(ph);
}
}
function removeSidebarPlaceholder() {
const sb = getSidebar();
if (!sb) return;
const ph = sb.querySelector('.sb-dnd-placeholder');
if (ph) ph.remove();
}
// -------------------- DnD core --------------------
function addTopZoneHighlight() { function addTopZoneHighlight() {
const top = getTopZone(); const top = getTopZone();
if (!top) return; if (!top) return;
@@ -525,6 +556,7 @@ function cleanupTopZoneAfterDrop() {
if (ph) ph.remove(); if (ph) ph.remove();
top.classList.remove('highlight'); top.classList.remove('highlight');
top.style.minHeight = ''; top.style.minHeight = '';
// ✅ fixed selector string here
const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0; const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0;
top.style.display = hasAny ? '' : 'none'; top.style.display = hasAny ? '' : 'none';
} }
@@ -539,11 +571,10 @@ function hideHeaderDropZone() {
const h = getHeaderDropArea(); const h = getHeaderDropArea();
if (h) { if (h) {
h.classList.remove('drag-active'); h.classList.remove('drag-active');
if (h.children.length === 0) h.style.display = 'none'; if (h.children.length === 0 && !isZonesCollapsed()) h.style.display = 'none';
} }
} }
// -------------------- DnD core --------------------
function makeCardDraggable(card) { function makeCardDraggable(card) {
if (!card) return; if (!card) return;
const header = card.querySelector('.card-header'); const header = card.querySelector('.card-header');
@@ -573,11 +604,9 @@ function makeCardDraggable(card) {
const sb = getSidebar(); const sb = getSidebar();
if (sb) { if (sb) {
sb.classList.add('active'); sb.classList.add('active', 'highlight');
sb.classList.add('highlight');
if (!isZonesCollapsed()) sb.style.display = 'block'; if (!isZonesCollapsed()) sb.style.display = 'block';
sb.style.removeProperty('height'); ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
sb.style.minWidth = '280px';
} }
showHeaderDropZone(); showHeaderDropZone();
@@ -597,9 +626,8 @@ function makeCardDraggable(card) {
top: initialTop + 'px', top: initialTop + 'px',
width: rect.width + 'px', width: rect.width + 'px',
height: rect.height + 'px', height: rect.height + 'px',
minWidth: rect.width + 'px', zIndex: '10000',
flexShrink: '0', pointerEvents: 'none'
zIndex: '10000'
}); });
}, 450); }, 450);
}); });
@@ -623,8 +651,7 @@ function makeCardDraggable(card) {
const sb = getSidebar(); const sb = getSidebar();
if (sb) { if (sb) {
sb.classList.remove('highlight'); sb.classList.remove('highlight');
sb.style.height = ''; removeSidebarPlaceholder();
sb.style.minWidth = '';
} }
let dropped = null; let dropped = null;
@@ -663,22 +690,20 @@ function makeCardDraggable(card) {
} }
} }
// If not dropped anywhere, return to original container
if (!dropped) { if (!dropped) {
// return to original container
const orig = $(card.dataset.originalContainerId); const orig = $(card.dataset.originalContainerId);
if (orig) { if (orig) {
orig.appendChild(card); orig.appendChild(card);
card.style.removeProperty('width'); card.style.removeProperty('width');
animateVerticalSlide(card); animateVerticalSlide(card);
// keep previous zone in layout (no change)
} }
} else { } else {
// Persist user layout on manual move (including header)
setLayoutFor(card.id, dropped); setLayoutFor(card.id, dropped);
} }
// Clear inline drag styles // Clear inline drag styles
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width'] ['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width', 'pointer-events']
.forEach(prop => card.style.removeProperty(prop)); .forEach(prop => card.style.removeProperty(prop));
removeTopZoneHighlight(); removeTopZoneHighlight();
@@ -686,15 +711,52 @@ function makeCardDraggable(card) {
cleanupTopZoneAfterDrop(); cleanupTopZoneAfterDrop();
updateTopZoneLayout(); updateTopZoneLayout();
updateSidebarVisibility(); updateSidebarVisibility();
updateZonesToggleUI();
}); });
} }
// -------------------- defaults + layout --------------------
function applyUserLayoutOrDefault() {
const layout = readLayout();
const hasAny = Object.keys(layout).length > 0;
if (hasAny) {
getCards().forEach(card => {
const targetZone = layout[card.id];
if (!targetZone) return;
// On small screens: if saved zone is the sidebar, temporarily place in top cols
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(card, target, { animate: false });
} else {
placeCardInZone(card, targetZone, { animate: false });
}
});
updateTopZoneLayout();
updateSidebarVisibility();
return;
}
// No saved layout yet: apply defaults
if (!isSmallScreen()) {
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
} else {
getCards().forEach(c => {
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
placeCardInZone(c, zone, { animate: false });
});
}
updateTopZoneLayout();
updateSidebarVisibility();
saveCurrentLayout(); // initialize baseline so future moves persist
}
// -------------------- public API -------------------- // -------------------- public API --------------------
export function loadSidebarOrder() { export function loadSidebarOrder() {
// Backward compat: act as "apply layout"
applyUserLayoutOrDefault(); applyUserLayoutOrDefault();
ensureZonesToggle(); ensureZonesToggle();
updateZonesToggleUI(); updateZonesToggleUI();
applyCollapsedBodyClass();
} }
export function loadHeaderOrder() { export function loadHeaderOrder() {
@@ -704,9 +766,9 @@ export function loadHeaderOrder() {
const layout = readLayout(); const layout = readLayout();
// If collapsed: all cards appear as header icons
if (isZonesCollapsed()) { if (isZonesCollapsed()) {
getCards().forEach(insertCardInHeader); getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
saveHeaderOrder(); saveHeaderOrder();
return; return;
} }
@@ -715,6 +777,7 @@ export function loadHeaderOrder() {
getCards().forEach(card => { getCards().forEach(card => {
if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card); if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card);
}); });
if (header.children.length === 0) header.style.display = 'none';
saveHeaderOrder(); saveHeaderOrder();
} }
@@ -727,6 +790,7 @@ export function initDragAndDrop() {
// 2) Paint controls/UI // 2) Paint controls/UI
ensureZonesToggle(); ensureZonesToggle();
updateZonesToggleUI(); updateZonesToggleUI();
applyCollapsedBodyClass();
// 3) Make cards draggable // 3) Make cards draggable
getCards().forEach(makeCardDraggable); getCards().forEach(makeCardDraggable);
@@ -735,9 +799,7 @@ export function initDragAndDrop() {
let raf = null; let raf = null;
const onResize = () => { const onResize = () => {
if (raf) cancelAnimationFrame(raf); if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => { raf = requestAnimationFrame(() => enforceResponsiveZones());
enforceResponsiveZones();
});
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
enforceResponsiveZones(); enforceResponsiveZones();

View File

@@ -86,7 +86,7 @@ export function getParentFolder(folder) {
Breadcrumb Functions Breadcrumb Functions
----------------------*/ ----------------------*/
function setControlEnabled(el, enabled) { function setControlEnabled(el, enabled) {
if (!el) return; if (!el) return;
if ('disabled' in el) el.disabled = !enabled; if ('disabled' in el) el.disabled = !enabled;
el.classList.toggle('disabled', !enabled); el.classList.toggle('disabled', !enabled);
@@ -101,7 +101,7 @@ async function applyFolderCapabilities(folder) {
const caps = await res.json(); const caps = await res.json();
window.currentFolderCaps = caps; window.currentFolderCaps = caps;
const isRoot = (folder === 'root'); const isRoot = (folder === 'root');
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
@@ -143,7 +143,7 @@ function breadcrumbClickHandler(e) {
updateBreadcrumbTitle(folder); updateBreadcrumbTitle(folder);
applyFolderCapabilities(folder); applyFolderCapabilities(folder);
expandTreePath(folder); expandTreePath(folder, { persist: false, includeLeaf: false });
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected")); document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`); const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected"); if (target) target.classList.add("selected");
@@ -184,7 +184,7 @@ function breadcrumbDropHandler(e) {
/* FOLDER MOVE FALLBACK */ /* FOLDER MOVE FALLBACK */
if (!dragData) { if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || 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) { if (plain) {
const sourceFolder = String(plain).trim(); const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") { if (sourceFolder && sourceFolder !== "root") {
@@ -208,7 +208,7 @@ function breadcrumbDropHandler(e) {
window.currentFolder = newPath; window.currentFolder = newPath;
} }
return loadFolderTree().then(() => { return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {} try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root"); loadFileList(window.currentFolder || "root");
}); });
} else { } else {
@@ -268,8 +268,8 @@ async function checkUserFolderPermission() {
const isFolderOnly = const isFolderOnly =
!!(permissionsData && !!(permissionsData &&
permissionsData[username] && permissionsData[username] &&
permissionsData[username].folderOnly); permissionsData[username].folderOnly);
window.userFolderOnly = isFolderOnly; window.userFolderOnly = isFolderOnly;
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false"); 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 DOM Building Functions for Folder Tree
----------------------*/ ----------------------*/
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") { function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
const state = loadFolderTreeState(); 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) { for (const folder in tree) {
const name = folder.toLowerCase(); const name = folder.toLowerCase();
if (name === "trash" || name === "profile_pics") continue; if (name === "trash" || name === "profile_pics") continue;
const fullPath = parentPath ? parentPath + "/" + folder : folder; const fullPath = parentPath ? parentPath + "/" + folder : folder;
const hasChildren = Object.keys(tree[folder]).length > 0; const hasChildren = Object.keys(tree[folder]).length > 0;
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay; 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) { if (hasChildren) {
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']'; html += `<button type="button" class="folder-toggle" aria-label="${isOpen ? 'Collapse' : 'Expand'}" data-folder="${fullPath}"></button>`;
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
} else { } else {
html += `<span class="folder-indent-placeholder"></span>`; html += `<span class="folder-spacer" aria-hidden="true"></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-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 += `</li>`;
} }
html += `</ul>`; html += `</ul>`;
return html; return html;
} }
function expandTreePath(path) { // replace your current expandTreePath with this version
const parts = path.split("/"); function expandTreePath(path, opts = {}) {
let cumulative = ""; const { force = false } = opts;
parts.forEach((part, index) => { const state = loadFolderTreeState();
cumulative = index === 0 ? part : cumulative + "/" + part; const parts = (path || '').split('/').filter(Boolean);
const option = document.querySelector(`.folder-option[data-folder="${cumulative}"]`); let cumulative = '';
if (option) {
const li = option.parentNode; parts.forEach((part, i) => {
const nestedUl = li.querySelector("ul"); cumulative = i === 0 ? part : `${cumulative}/${part}`;
if (nestedUl && (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded"))) { const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
nestedUl.classList.remove("collapsed"); if (!option) return;
nestedUl.classList.add("expanded");
const toggle = li.querySelector(".folder-toggle"); const li = option.closest('li[role="treeitem"]');
if (toggle) { const nestedUl = li ? li.querySelector(':scope > ul') : null;
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]"; if (!nestedUl) return;
const state = loadFolderTreeState();
state[cumulative] = "block"; // Only expand if caller forces it OR saved state says "block"
saveFolderTreeState(state); 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 Drag & Drop Support for Folder Tree Nodes
----------------------*/ ----------------------*/
@@ -360,15 +518,15 @@ function folderDropHandler(event) {
try { try {
const jsonStr = event.dataTransfer.getData("application/json") || ""; const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr); if (jsonStr) dragData = JSON.parse(jsonStr);
} }
catch (e) { catch (e) {
console.error("Invalid drag data", e); console.error("Invalid drag data", e);
return; return;
} }
/* FOLDER MOVE FALLBACK */ /* FOLDER MOVE FALLBACK */
if (!dragData) { if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || 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) { if (plain) {
const sourceFolder = String(plain).trim(); const sourceFolder = String(plain).trim();
if (sourceFolder && sourceFolder !== "root") { if (sourceFolder && sourceFolder !== "root") {
@@ -392,7 +550,7 @@ function folderDropHandler(event) {
window.currentFolder = newPath; window.currentFolder = newPath;
} }
return loadFolderTree().then(() => { return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root"); } catch (_) {} try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root"); loadFileList(window.currentFolder || "root");
}); });
} else { } else {
@@ -442,22 +600,29 @@ function folderDropHandler(event) {
// Safe breadcrumb DOM builder // Safe breadcrumb DOM builder
function renderBreadcrumbFragment(folderPath) { function renderBreadcrumbFragment(folderPath) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const parts = folderPath.split("/");
let acc = "";
parts.forEach((part, idx) => { // Defensive normalize
acc = idx === 0 ? part : acc + "/" + part; const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
const crumbs = path.split('/').filter(s => s !== ''); // no empty segments
const span = document.createElement("span"); let acc = '';
span.classList.add("breadcrumb-link"); 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.dataset.folder = acc;
span.textContent = part; span.textContent = part;
frag.appendChild(span); frag.appendChild(span);
if (idx < parts.length - 1) { if (i < crumbs.length - 1) {
frag.appendChild(document.createTextNode(" / ")); const sep = document.createElement('span');
sep.className = 'file-breadcrumb-sep';
sep.textContent = '';
frag.appendChild(sep);
} }
}); }
return frag; return frag;
} }
@@ -536,23 +701,61 @@ export async function loadFolderTree(selectedFolder) {
return; return;
} }
let html = `<div id="rootRow" class="root-row"> const state0 = loadFolderTreeState();
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span> const rootOpen = state0[effectiveRoot] !== 'none';
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
</div>`; 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) { if (folders.length > 0) {
const tree = buildFolderTree(folders); 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; 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. // Attach drag/drop event listeners.
container.querySelectorAll(".folder-option").forEach(el => { container.querySelectorAll(".folder-option").forEach(el => {
const fp = el.getAttribute('data-folder');
markNonEmptyIfHasChildren(fp);
// Provide folder path payload for folder->folder DnD // Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => { el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder"); const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {} try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {} try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
ev.dataTransfer.effectAllowed = "move"; ev.dataTransfer.effectAllowed = "move";
}); });
@@ -569,11 +772,12 @@ export async function loadFolderTree(selectedFolder) {
// Initial breadcrumb + file list // Initial breadcrumb + file list
updateBreadcrumbTitle(window.currentFolder); updateBreadcrumbTitle(window.currentFolder);
applyFolderCapabilities(window.currentFolder); applyFolderCapabilities(window.currentFolder);
ensureFolderIcon(window.currentFolder);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
const folderState = loadFolderTreeState(); // Show ancestors so the current selection is visible, but don't persist
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") { if (window.currentFolder && window.currentFolder !== effectiveRoot) {
expandTreePath(window.currentFolder); expandTreePath(window.currentFolder, { persist: false, includeLeaf: false });
} }
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`); 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 // Provide folder path payload for folder->folder DnD
el.addEventListener("dragstart", (ev) => { el.addEventListener("dragstart", (ev) => {
const src = el.getAttribute("data-folder"); const src = el.getAttribute("data-folder");
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {} try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {} try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
ev.dataTransfer.effectAllowed = "move"; ev.dataTransfer.effectAllowed = "move";
}); });
@@ -602,55 +806,48 @@ export async function loadFolderTree(selectedFolder) {
updateBreadcrumbTitle(selected); updateBreadcrumbTitle(selected);
applyFolderCapabilities(selected); applyFolderCapabilities(selected);
ensureFolderIcon(selected);
loadFileList(selected); loadFileList(selected);
}); });
}); });
// Root toggle handler // Root toggle
const rootToggle = container.querySelector("#rootRow .folder-toggle"); const rootToggle = container.querySelector("#rootRow .folder-toggle");
if (rootToggle) { if (rootToggle) {
rootToggle.addEventListener("click", function (e) { rootToggle.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
const nestedUl = container.querySelector("#rootRow + ul"); const nestedUl = container.querySelector("#rootRow + ul");
if (nestedUl) { if (!nestedUl) return;
const state = loadFolderTreeState();
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) { const state = loadFolderTreeState();
nestedUl.classList.remove("collapsed"); const expanded = !(nestedUl.classList.contains("expanded"));
nestedUl.classList.add("expanded"); nestedUl.classList.toggle("expanded", expanded);
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]"; nestedUl.classList.toggle("collapsed", !expanded);
state[effectiveRoot] = "block";
} else { document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded));
nestedUl.classList.remove("expanded"); state[effectiveRoot] = expanded ? "block" : "none";
nestedUl.classList.add("collapsed"); saveFolderTreeState(state);
this.textContent = "[+]";
state[effectiveRoot] = "none";
}
saveFolderTreeState(state);
}
}); });
} }
// Other folder-toggle handlers // Other toggles
container.querySelectorAll(".folder-toggle").forEach(toggle => {
container.querySelectorAll("button.folder-toggle").forEach(toggle => {
toggle.addEventListener("click", function (e) { toggle.addEventListener("click", function (e) {
e.stopPropagation(); 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"); const folderPath = this.getAttribute("data-folder");
if (!siblingUl) return;
const state = loadFolderTreeState(); const state = loadFolderTreeState();
if (siblingUl) { const expanded = !(siblingUl.classList.contains("expanded"));
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) { siblingUl.classList.toggle("expanded", expanded);
siblingUl.classList.remove("collapsed"); siblingUl.classList.toggle("collapsed", !expanded);
siblingUl.classList.add("expanded"); li.setAttribute("aria-expanded", String(expanded));
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]"; state[folderPath] = expanded ? "block" : "none";
state[folderPath] = "block"; saveFolderTreeState(state);
} else { ensureFolderIcon(folderPath);
siblingUl.classList.remove("expanded");
siblingUl.classList.add("collapsed");
this.textContent = "[+]";
state[folderPath] = "none";
}
saveFolderTreeState(state);
}
}); });
}); });
@@ -749,7 +946,7 @@ if (submitRename) {
// === Move Folder Modal helper (shared by button + context menu) === // === Move Folder Modal helper (shared by button + context menu) ===
function openMoveFolderUI(sourceFolder) { function openMoveFolderUI(sourceFolder) {
const modal = document.getElementById('moveFolderModal'); const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget'); const targetSel = document.getElementById('moveFolderTarget');
// If you right-clicked a different folder than currently selected, use that // If you right-clicked a different folder than currently selected, use that
@@ -779,7 +976,7 @@ function openMoveFolderUI(sourceFolder) {
targetSel.appendChild(o); targetSel.appendChild(o);
}); });
}) })
.catch(()=>{ /* no-op */ }); .catch(() => { /* no-op */ });
} }
if (modal) modal.style.display = 'block'; if (modal) modal.style.display = 'block';
@@ -1073,11 +1270,11 @@ document.addEventListener("DOMContentLoaded", function () {
bindFolderManagerContextMenu(); bindFolderManagerContextMenu();
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const moveBtn = document.getElementById('moveFolderBtn'); const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal'); const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget'); const targetSel = document.getElementById('moveFolderTarget');
const cancelBtn = document.getElementById('cancelMoveFolder'); const cancelBtn = document.getElementById('cancelMoveFolder');
const confirmBtn= document.getElementById('confirmMoveFolder'); const confirmBtn = document.getElementById('confirmMoveFolder');
if (moveBtn) { if (moveBtn) {
moveBtn.addEventListener('click', () => { moveBtn.addEventListener('click', () => {
@@ -1092,7 +1289,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (confirmBtn) confirmBtn.addEventListener('click', async () => { if (confirmBtn) confirmBtn.addEventListener('click', async () => {
if (!targetSel) return; if (!targetSel) return;
const destination = targetSel.value; const destination = targetSel.value;
const source = window.currentFolder; const source = window.currentFolder;
if (!destination) { showToast('Pick a destination'); return; } if (!destination) { showToast('Pick a destination'); return; }
if (destination === source || (destination + '/').startsWith(source + '/')) { if (destination === source || (destination + '/').startsWith(source + '/')) {
@@ -1108,7 +1305,7 @@ document.addEventListener("DOMContentLoaded", () => {
const data = await safeJson(res); const data = await safeJson(res);
if (res.ok && data && !data.error) { if (res.ok && data && !data.error) {
showToast('Folder moved'); showToast('Folder moved');
if (modal) modal.style.display='none'; if (modal) modal.style.display = 'none';
await loadFolderTree(); await loadFolderTree();
const base = source.split('/').pop(); const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base; const newPath = (destination === 'root' ? '' : destination + '/') + base;

View File

@@ -1,2 +1,2 @@
// generated by CI // generated by CI
window.APP_VERSION = 'v1.8.12'; window.APP_VERSION = 'v1.9.0';

View File

@@ -30,6 +30,13 @@ class FolderController
return $headers; 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 private static function requireCsrf(): void
{ {
self::ensureSession(); self::ensureSession();

View File

@@ -10,6 +10,45 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json) * 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. */ /** Load the folder → owner map. */
public static function getFolderOwners(): array public static function getFolderOwners(): array
{ {