From f5e42a2e819a5c090f933f81eab52f60cc586ba0 Mon Sep 17 00:00:00 2001 From: Ryan Date: Thu, 13 Nov 2025 05:06:24 -0500 Subject: [PATCH] =?UTF-8?q?release(v1.9.4):=20lazy=20folder=20tree,=20curs?= =?UTF-8?q?or=20pagination,=20ACL-safe=20chevrons,=20and=20=E2=80=9CLoad?= =?UTF-8?q?=20more=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lazy folder tree via /api/folder/listChildren.php with cursor pagination - ACL-safe chevrons using hasSubfolders from server; no file-count leaks - BFS smart initial folder selection + respect lastOpenedFolder - Locked nodes are expandable but not selectable - “Load more” UX (light & dark) for huge directories Closes #66 --- CHANGELOG.md | 56 + public/api/folder/capabilities.php | 249 +-- public/api/folder/isEmpty.php | 30 +- public/api/folder/listChildren.php | 31 + public/css/styles.css | 64 + public/index.html | 26 +- public/js/folderManager.js | 2594 ++++++++++++++------------ public/js/i18n.js | 14 +- src/controllers/FolderController.php | 185 +- src/controllers/MediaController.php | 38 +- src/lib/FS.php | 87 + src/models/FolderModel.php | 331 +++- 12 files changed, 2184 insertions(+), 1521 deletions(-) create mode 100644 public/api/folder/listChildren.php create mode 100644 src/lib/FS.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8a884..6a53660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## Changes 11/13/2025 (v1.9.4) + +release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66) + +**Big focus on folder management performance & UX for large libraries.** + +feat(folder-tree): + +- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row. +- BFS-based initial selection: if user can’t view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it). +- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead). +- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation). +- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable). +- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share. +- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible. +- Root DnD honored only when viewable; structural locks disable dragging. + +perf(core): + +- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited). +- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete. +- Bounded concurrency for folder count requests; short timeouts to keep UI snappy. + +api/model: + +- `FolderModel::listChildren(...)` now returns items shaped like: + `{ name, locked, hasSubfolders, nonEmpty? }` + - `nonEmpty` included only for unlocked nodes (prevents side-channel leakage). + - Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior). +- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks. +- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”. + +ui/ux: + +- New “Load more” row (`
  • `) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state. +- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load. +- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc. +- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”. + +security: + +- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored). +- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks. + +fixes: + +- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant). +- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder. + +chore: + +- CSS additions for locked state, tree rows, and dark-mode ghost buttons. +- Minor code cleanups and comments across controller/model and JS tree logic. + +--- + ## Changes 11/11/2025 (v1.9.3) release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release diff --git a/public/api/folder/capabilities.php b/public/api/folder/capabilities.php index 3f75b1d..b21c098 100644 --- a/public/api/folder/capabilities.php +++ b/public/api/folder/capabilities.php @@ -1,245 +1,18 @@ 'Unauthorized']); exit; } +@session_write_close(); -// --- auth --- -$username = $_SESSION['username'] ?? ''; -if ($username === '') { - http_response_code(401); - echo json_encode(['error' => 'Unauthorized']); - exit; -} +$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'; +$folder = str_replace('\\', '/', trim($folder)); +$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/'); -// --- helpers --- -function loadPermsFor(string $u): array { - try { - if (function_exists('loadUserPermissions')) { - $p = loadUserPermissions($u); - return is_array($p) ? $p : []; - } - if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) { - $all = userModel::getUserPermissions(); - if (is_array($all)) { - if (isset($all[$u])) return (array)$all[$u]; - $lk = strtolower($u); - if (isset($all[$lk])) return (array)$all[$lk]; - } - } - } catch (Throwable $e) {} - return []; -} - -function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool { - $f = ACL::normalizeFolder($folder); - // direct owner - if (ACL::isOwner($user, $perms, $f)) return true; - // ancestor owner - while ($f !== '' && strcasecmp($f, 'root') !== 0) { - $pos = strrpos($f, '/'); - if ($pos === false) break; - $f = substr($f, 0, $pos); - if ($f === '' || strcasecmp($f, 'root') === 0) break; - if (ACL::isOwner($user, $perms, $f)) return true; - } - return false; -} - -/** - * folder-only scope: - * - Admins: always in scope - * - Non folder-only accounts: always in scope - * - Folder-only accounts: in scope iff: - * - folder == username OR subpath of username, OR - * - user is owner of this folder (or any ancestor) - */ -function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool { - if ($isAdmin) return true; - //$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']); - //if (!$folderOnly) return true; - - $f = ACL::normalizeFolder($folder); - if ($f === 'root' || $f === '') { - // folder-only users cannot act on root unless they own a subfolder (handled below) - return isOwnerOrAncestorOwner($u, $perms, $f); - } - - if ($f === $u || str_starts_with($f, $u . '/')) return true; - - // Treat ownership as in-scope - return isOwnerOrAncestorOwner($u, $perms, $f); -} - -// --- inputs --- -$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root'; - -// validate folder path -if ($folder !== 'root') { - $parts = array_filter(explode('/', trim($folder, "/\\ "))); - if (empty($parts)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid folder name.']); - exit; - } - foreach ($parts as $seg) { - if (!preg_match(REGEX_FOLDER_NAME, $seg)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid folder name.']); - exit; - } - } - $folder = implode('/', $parts); -} - -// --- user + flags --- -$perms = loadPermsFor($username); -$isAdmin = ACL::isAdmin($perms); -$readOnly = !empty($perms['readOnly']); -$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin); - -// --- ACL base abilities --- -$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder); -$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder); -$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder); -$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder); - -$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder); - -// granular base -$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder); -$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder); -$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder); -$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder); -$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder); -$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder); -$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder); -$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder); -$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder); -$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder); - -// --- Apply scope + flags to effective UI actions --- -$canView = $canViewBase && $inScope; // keep scope for folder-only -$canUpload = $gUploadBase && !$readOnly && $inScope; -$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder** -$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder** -$canDelete = $gDeleteBase && !$readOnly && $inScope; -// Destination can receive items if user can create/write (or manage) here -$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope; -// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop) -$canMoveIn = $canReceive; -$canMoveAlias = $canMoveIn; -$canEdit = $gEditBase && !$readOnly && $inScope; -$canCopy = $gCopyBase && !$readOnly && $inScope; -$canExtract = $gExtractBase && !$readOnly && $inScope; - -// Sharing respects scope; optionally also gate on readOnly -$canShare = $canShareBase && $inScope; // legacy umbrella -$canShareFileEff = $gShareFile && $inScope; -$canShareFoldEff = $gShareFolder && $inScope; - -// never allow destructive ops on root -$isRoot = ($folder === 'root'); -if ($isRoot) { - $canRename = false; - $canDelete = false; - $canShareFoldEff = false; - $canMoveFolder = false; -} - -if (!$isRoot) { - $canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder)) - && !$readOnly; -} - -$owner = null; -try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {} - -echo json_encode([ - 'user' => $username, - 'folder' => $folder, - 'isAdmin' => $isAdmin, - 'flags' => [ - //'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']), - 'readOnly' => $readOnly, - ], - 'owner' => $owner, - - // viewing - 'canView' => $canView, - 'canViewOwn' => $canViewOwn, - - // write-ish - 'canUpload' => $canUpload, - 'canCreate' => $canCreate, - 'canRename' => $canRename, - 'canDelete' => $canDelete, - 'canMoveIn' => $canMoveIn, - 'canMove' => $canMoveAlias, - 'canMoveFolder'=> $canMoveFolder, - 'canEdit' => $canEdit, - 'canCopy' => $canCopy, - 'canExtract' => $canExtract, - - // sharing - 'canShare' => $canShare, // legacy - 'canShareFile' => $canShareFileEff, - 'canShareFolder' => $canShareFoldEff, -], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); \ No newline at end of file +echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/public/api/folder/isEmpty.php b/public/api/folder/isEmpty.php index cd3c27d..9d66f2d 100644 --- a/public/api/folder/isEmpty.php +++ b/public/api/folder/isEmpty.php @@ -1,30 +1,28 @@ 'Unauthorized']); exit; } + +$username = (string)($_SESSION['username'] ?? ''); $perms = [ - 'role' => $_SESSION['role'] ?? null, - 'admin' => $_SESSION['admin'] ?? null, - 'isAdmin' => $_SESSION['isAdmin'] ?? null, + 'role' => $_SESSION['role'] ?? null, + 'admin' => $_SESSION['admin'] ?? null, + 'isAdmin' => $_SESSION['isAdmin'] ?? null, + 'folderOnly' => $_SESSION['folderOnly'] ?? null, + 'readOnly' => $_SESSION['readOnly'] ?? 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, '/'); +$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/'); -// Delegate to controller (model handles ACL + path safety) -$result = FolderController::stats($folder, $user, $perms); - -// Always return a compact JSON object like before -echo json_encode([ - 'folders' => (int)($result['folders'] ?? 0), - 'files' => (int)($result['files'] ?? 0), -]); \ No newline at end of file +echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/public/api/folder/listChildren.php b/public/api/folder/listChildren.php new file mode 100644 index 0000000..6cd1c11 --- /dev/null +++ b/public/api/folder/listChildren.php @@ -0,0 +1,31 @@ +'Unauthorized']); exit; } + +$username = (string)($_SESSION['username'] ?? ''); +$perms = [ + 'role' => $_SESSION['role'] ?? null, + 'admin' => $_SESSION['admin'] ?? null, + 'isAdmin' => $_SESSION['isAdmin'] ?? null, + 'folderOnly' => $_SESSION['folderOnly'] ?? null, + 'readOnly' => $_SESSION['readOnly'] ?? null, +]; +@session_write_close(); + +$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'; +$folder = str_replace('\\', '/', trim($folder)); +$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/'); + +$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500))); +$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null; + +$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit); +echo json_encode($res, JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index 5c0134c..401f82e 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -2413,3 +2413,67 @@ body.dark-mode .folder-strip-container .folder-item:hover { .folder-strip-container .folder-svg .paper-line, .folder-strip-container .folder-svg .paper-ink, .folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; } +/* Locked folder look (keep subtle but clear) */ +#folderTreeContainer .folder-option.locked { + opacity: .9; + font-style: italic; +} + +/* Padlock styling inside the SVG */ +#folderTreeContainer .folder-icon .lock-body { + fill: var(--filr-folder-stroke, #6b6b6b); + opacity: .95; +} +#folderTreeContainer .folder-icon .lock-shackle { + fill: none; + stroke: var(--filr-folder-stroke, #6b6b6b); + stroke-width: 1.4; + stroke-linecap: round; +} +#folderTreeContainer .folder-icon .lock-keyhole { + fill: rgba(0,0,0,.28); +} +body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole { + fill: rgba(255,255,255,.28); +} + +#folderTreeContainer li.load-more { + padding: 4px 0 4px 28px; /* indent to line up with rows */ + list-style: none; +} + +#folderTreeContainer li.load-more > .btn.btn-ghost { + width: calc(100% - 8px); + margin: 0 4px; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + border-radius: 10px; + border: 1px solid var(--tree-ghost-border); + background: var(--tree-ghost-bg); + color: var(--tree-ghost-fg); + padding: 6px 10px; + font-size: 12.5px; +} + +#folderTreeContainer li.load-more > .btn.btn-ghost:hover { + background: var(--tree-ghost-hover-bg); +} + +#folderTreeContainer li.load-more > .btn.btn-ghost:focus-visible { + outline: 2px solid #8ab4f8; + outline-offset: 2px; +} + +/* tiny spinner when busy */ +#folderTreeContainer li.load-more > .btn[aria-busy="true"]::before { + content: ""; + width: 12px; height: 12px; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; + display: inline-block; + animation: filr-spin .8s linear infinite; +} +@keyframes filr-spin { to { transform: rotate(360deg); } } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 7eb5a79..a1ac6b0 100644 --- a/public/index.html +++ b/public/index.html @@ -277,14 +277,24 @@ diff --git a/public/js/folderManager.js b/public/js/folderManager.js index a1d356a..383694a 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -1,4 +1,6 @@ -// folderManager.js +// public/js/folderManager.js +// Lazy folder tree with persisted expansion, root DnD, color-carry on moves, and state migration. +// Smart initial selection: if the default folder isn't viewable, pick the first accessible folder (BFS). import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { showToast, escapeHTML, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}'; @@ -7,21 +9,18 @@ import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}'; import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}'; import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}'; + +const PAGE_LIMIT = 100; + /* ---------------------- Helpers: safe JSON + state ----------------------*/ - -// Robust JSON reader that surfaces server errors (with status) async function safeJson(res) { const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } - if (!res.ok) { - const msg = - (body && (body.error || body.message)) || - (text && text.trim()) || - `HTTP ${res.status}`; + const msg = (body && (body.error || body.message)) || (text && text.trim()) || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; @@ -29,70 +28,109 @@ async function safeJson(res) { return body ?? {}; } -/* ---------------------- - Helper Functions (Data/State) -----------------------*/ +function disableAllFolderControls() { + ['createFolderBtn','moveFolderBtn','renameFolderBtn','colorFolderBtn','deleteFolderBtn','shareFolderBtn'] + .forEach(id => setControlEnabled(document.getElementById(id), false)); +} -// Formats a folder name for display (e.g. adding indentations). +function markOptionLocked(optEl, locked) { + if (!optEl) return; + optEl.classList.toggle('locked', !!locked); + + // Disable DnD when locked + if (locked) optEl.removeAttribute('draggable'); + + // Refresh the icon with padlock overlay + const iconEl = optEl.querySelector('.folder-icon'); + if (iconEl) { + const currentKind = iconEl?.dataset?.kind || 'empty'; + iconEl.innerHTML = folderSVG(currentKind, { locked: !!locked }); + } +} + +/* ---------------------- + Simple format + parent helpers (exported for other modules) +----------------------*/ export function formatFolderName(folder) { if (typeof folder !== "string") return ""; if (folder.indexOf("/") !== -1) { const parts = folder.split("/"); let indent = ""; - for (let i = 1; i < parts.length; i++) { - indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level - } + for (let i = 1; i < parts.length; i++) indent += "\\u00A0\\u00A0\\u00A0\\u00A0"; return indent + parts[parts.length - 1]; - } else { - return folder; } + return folder; } - -// Build a tree structure from a flat array of folder paths. -function buildFolderTree(folders) { - const tree = {}; - folders.forEach(folderPath => { - if (typeof folderPath !== "string") return; - const parts = folderPath.split('/'); - let current = tree; - parts.forEach(part => { - if (!current[part]) current[part] = {}; - current = current[part]; - }); - }); - return tree; -} - -/* ---------------------- - Folder Tree State (Save/Load) -----------------------*/ -function loadFolderTreeState() { - const state = localStorage.getItem("folderTreeState"); - return state ? JSON.parse(state) : {}; -} - -function saveFolderTreeState(state) { - localStorage.setItem("folderTreeState", JSON.stringify(state)); -} -/* ---------------------- - Transient UI guards (click suppression) -----------------------*/ -let _suppressToggleUntil = 0; -function suppressNextToggle(ms = 300) { - _suppressToggleUntil = performance.now() + ms; -} - -// Helper for getting the parent folder. export function getParentFolder(folder) { if (folder === "root") return "root"; const lastSlash = folder.lastIndexOf("/"); return lastSlash === -1 ? "root" : folder.substring(0, lastSlash); } -/* ---------------------- - Breadcrumb Functions - ----------------------*/ +function normalizeItem(it) { + if (it == null) return null; + if (typeof it === 'string') return { name: it, locked: false, hasSubfolders: undefined, nonEmpty: undefined }; + if (typeof it === 'object') { + const nm = String(it.name ?? '').trim(); + if (!nm) return null; + return { + name: nm, + locked: !!it.locked, + hasSubfolders: (typeof it.hasSubfolders === 'boolean') ? it.hasSubfolders : undefined, + nonEmpty: (typeof it.nonEmpty === 'boolean') ? it.nonEmpty : undefined, + }; + } + return null; +} +/* ---------------------- + Folder Tree State (Save/Load) +----------------------*/ +// ---- peekHasFolders helper (chevron truth from listChildren) ---- +if (!window._frPeekCache) window._frPeekCache = new Map(); +function peekHasFolders(folder) { + try { + const cache = window._frPeekCache; + if (cache.has(folder)) return cache.get(folder); + const p = (async () => { + try { + const res = await fetchChildrenOnce(folder); + return !!(Array.isArray(res?.items) && res.items.length > 0) || !!res?.nextCursor; + } catch { return false; } + })(); + cache.set(folder, p); + return p; + } catch { return Promise.resolve(false); } +} +// small helper to clear peek cache for specific folders (or all if none provided) +function clearPeekCache(folders) { + try { + const c = window._frPeekCache; + if (!c) return; + if (!folders || !folders.length) { c.clear(); return; } + folders.forEach(f => c.delete(f)); + } catch {} +} +try { window.peekHasFolders = peekHasFolders; } catch {} +// ---- end peekHasFolders ---- + +function loadFolderTreeState() { + const state = localStorage.getItem("folderTreeState"); + return state ? JSON.parse(state) : {}; +} +function saveFolderTreeState(state) { + localStorage.setItem("folderTreeState", JSON.stringify(state)); +} + +/* ---------------------- + Transient UI guards (click suppression) +----------------------*/ +let _suppressToggleUntil = 0; +function suppressNextToggle(ms = 300) { _suppressToggleUntil = performance.now() + ms; } + +/* ---------------------- + Capability helpers +----------------------*/ function setControlEnabled(el, enabled) { if (!el) return; if ('disabled' in el) el.disabled = !enabled; @@ -101,195 +139,465 @@ function setControlEnabled(el, enabled) { el.style.pointerEvents = enabled ? '' : 'none'; el.style.opacity = enabled ? '' : '0.5'; } - async function applyFolderCapabilities(folder) { - const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' }); - if (!res.ok) return; - const caps = await res.json(); - window.currentFolderCaps = caps; - - const isRoot = (folder === 'root'); - - setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); - setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); - setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); - setControlEnabled(document.getElementById('colorFolderBtn'), !isRoot && !!caps.canRename); - setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete); - setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder); + try { + const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' }); + if (!res.ok) { disableAllFolderControls(); return; } + const caps = await res.json(); + window.currentFolderCaps = caps; + const isRoot = (folder === 'root'); + setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); + setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); + setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); + setControlEnabled(document.getElementById('colorFolderBtn'), !isRoot && !!caps.canEdit); + setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDeleteFolder); + setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder); + } catch { + disableAllFolderControls(); + } +} +// returns boolean whether user can view given folder +async function canViewFolder(folder) { + try { + const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' }); + if (!res.ok) return false; + const caps = await res.json(); + // prefer explicit flag; otherwise compose from older keys + return !!(caps.canView ?? caps.canRead ?? caps.canReadOwn ?? caps.isAdmin); + } catch { return false; } } -// --- Breadcrumb Delegation Setup --- +/** + * BFS: starting at `startFolder`, find the first folder the user can view. + * - Skips "trash" and "profile_pics" + * - Honors server-side "locked" from listChildren, but still double-checks capabilities + * - Hard limit to avoid endless walks + */ +async function findFirstAccessibleFolder(startFolder = 'root') { + const MAX_VISITS = 3000; + const visited = new Set(); + const q = [startFolder]; + + while (q.length && visited.size < MAX_VISITS) { + const f = q.shift(); + if (!f || visited.has(f)) continue; + visited.add(f); + + // Check viewability + if (await canViewFolder(f)) return f; + + // Enqueue children for BFS + try { + const payload = await fetchChildrenOnce(f); + const items = (payload?.items || []); + for (const it of items) { + const name = (typeof it === 'string') ? it : (it && it.name); + if (!name) continue; + const lower = String(name).toLowerCase(); + if (lower === 'trash' || lower === 'profile_pics') continue; + const child = (f === 'root') ? name : `${f}/${name}`; + if (!visited.has(child)) q.push(child); + } + // If there are more pages, we only need one page to keep BFS order lightweight + } catch { /* ignore and continue */ } + } + return null; // none found +} +function showNoAccessEmptyState() { + const host = + document.getElementById('fileListContainer') || + document.getElementById('fileList') || + document.querySelector('.file-list-container'); + + if (!host) return; + + // Clear whatever was there (e.g., “No Files Found”) + host.innerHTML = ` +
    + ${t('no_access') || 'You do not have access to this resource.'} +
    + `; +} +/* ---------------------- + Breadcrumb +----------------------*/ +function renderBreadcrumbFragment(folderPath) { + const frag = document.createDocumentFragment(); + const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root'; + const crumbs = path.split('/').filter(Boolean); + 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 (i < crumbs.length - 1) { + const sep = document.createElement('span'); + sep.className = 'file-breadcrumb-sep'; + sep.textContent = '›'; + frag.appendChild(sep); + } + } + return frag; +} +export function updateBreadcrumbTitle(folder) { + const titleEl = document.getElementById("fileListTitle"); + if (!titleEl) return; + titleEl.textContent = ""; + titleEl.appendChild(document.createTextNode(t("files_in") + " (")); + titleEl.appendChild(renderBreadcrumbFragment(folder)); + titleEl.appendChild(document.createTextNode(")")); + setupBreadcrumbDelegation(); + bindFolderManagerContextMenu(); +} export function setupBreadcrumbDelegation() { const container = document.getElementById("fileListTitle"); - if (!container) { - console.error("Breadcrumb container (fileListTitle) not found."); - return; - } - // Remove any existing event listeners to avoid duplicates. + if (!container) return; container.removeEventListener("click", breadcrumbClickHandler); container.removeEventListener("dragover", breadcrumbDragOverHandler); container.removeEventListener("dragleave", breadcrumbDragLeaveHandler); container.removeEventListener("drop", breadcrumbDropHandler); - - // Attach delegated listeners container.addEventListener("click", breadcrumbClickHandler); container.addEventListener("dragover", breadcrumbDragOverHandler); container.addEventListener("dragleave", breadcrumbDragLeaveHandler); container.addEventListener("drop", breadcrumbDropHandler); } - -// Click handler via delegation -function breadcrumbClickHandler(e) { +async function breadcrumbClickHandler(e) { const link = e.target.closest(".breadcrumb-link"); if (!link) return; - e.stopPropagation(); e.preventDefault(); - const folder = link.dataset.folder; - window.currentFolder = folder; - localStorage.setItem("lastOpenedFolder", folder); - - updateBreadcrumbTitle(folder); - applyFolderCapabilities(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"); - applyFolderCapabilities(window.currentFolder); - - loadFileList(folder); + await selectFolder(folder); // will toast + bail if not allowed } - -// Dragover handler via delegation function breadcrumbDragOverHandler(e) { const link = e.target.closest(".breadcrumb-link"); if (!link) return; e.preventDefault(); link.classList.add("drop-hover"); } - -// Dragleave handler via delegation function breadcrumbDragLeaveHandler(e) { const link = e.target.closest(".breadcrumb-link"); if (!link) return; link.classList.remove("drop-hover"); } - -// Drop handler via delegation function breadcrumbDropHandler(e) { const link = e.target.closest(".breadcrumb-link"); if (!link) return; e.preventDefault(); link.classList.remove("drop-hover"); const dropFolder = link.getAttribute("data-folder"); + handleDropOnFolder(e, dropFolder); +} - let dragData; +/* ---------------------- + Folder-only scope (server truthy) +----------------------*/ +async function checkUserFolderPermission() { + const username = localStorage.getItem("username") || ""; try { - dragData = JSON.parse(e.dataTransfer.getData("application/json")); - } catch (_) { /* noop */ } + const res = await fetchWithCsrf("/api/getUserPermissions.php", { + method: "GET", + credentials: "include" + }); + const permissionsData = await safeJson(res); + const isFolderOnly = + !!(permissionsData && permissionsData[username] && permissionsData[username].folderOnly); + window.userFolderOnly = isFolderOnly; + localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false"); + if (isFolderOnly && username) { + localStorage.setItem("lastOpenedFolder", username); + window.currentFolder = username; + } + return isFolderOnly; + } catch { + window.userFolderOnly = false; + localStorage.setItem("folderOnly", "false"); + return false; + } +} - // FOLDER MOVE FALLBACK (folder->folder) - if (!dragData) { - const plain = (e.dataTransfer && e.dataTransfer.getData("application/x-filerise-folder")) || - (e.dataTransfer && e.dataTransfer.getData("text/plain")) || ""; - const sourceFolder = String(plain || "").trim(); - if (!sourceFolder || sourceFolder === "root") return; +/* ---------------------- + Local state and caches +----------------------*/ +const _folderCountCache = new Map(); // folderPath -> {folders, files} +const _inflightCounts = new Map(); // folderPath -> Promise +const _nonEmptyCache = new Map(); // folderPath -> bool +const _childCache = new Map(); // folderPath -> {items, nextCursor} - if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { - showToast("Invalid destination.", 4000); - return; +// --- Capability cache so we don't spam /capabilities.php +const _capViewCache = new Map(); +async function canViewFolderCached(folder) { + if (_capViewCache.has(folder)) return _capViewCache.get(folder); + const p = canViewFolder(folder).then(Boolean).catch(() => false); + _capViewCache.set(folder, p); + return p; +} + +// Returns true if `folder` has any *unlocked* descendant within maxDepth. +// Uses listChildren’s locked flag; depth defaults to 2 (fast). +async function hasUnlockedDescendant(folder, maxDepth = 2) { + try { + if (maxDepth <= 0) return false; + const { items = [] } = await fetchChildrenOnce(folder); + // Any direct unlocked child? + for (const it of items) { + const name = typeof it === 'string' ? it : it?.name; + const locked = typeof it === 'object' ? !!it.locked : false; + if (!name) continue; + if (!locked) return true; // found an unlocked child + } + // Otherwise, go one level deeper (light, bounded) + if (maxDepth > 1) { + for (const it of items) { + const name = typeof it === 'string' ? it : it?.name; + if (!name) continue; + const child = folder === 'root' ? name : `${folder}/${name}`; + // Skip known non-folders, but listChildren only returns dirs for us + if (await hasUnlockedDescendant(child, maxDepth - 1)) return true; + } + } + } catch {} + return false; +} + +async function chooseInitialFolder(effectiveRoot, selectedFolder) { + // 1) explicit selection + if (selectedFolder && await canViewFolderCached(selectedFolder)) return selectedFolder; + + // 2) sticky lastOpenedFolder + const last = localStorage.getItem("lastOpenedFolder"); + if (last && await canViewFolderCached(last)) return last; + + // 3) NEW: if root itself is viewable, prefer (Root) + if (await canViewFolderCached(effectiveRoot)) return effectiveRoot; + + // 4) first TOP-LEVEL child that’s directly viewable + try { + const { items = [] } = await fetchChildrenOnce(effectiveRoot); + const topNames = items.map(it => (typeof it === 'string' ? it : it?.name)).filter(Boolean); + + for (const name of topNames) { + const child = effectiveRoot === 'root' ? name : `${effectiveRoot}/${name}`; + if (await canViewFolderCached(child)) return child; } - fetchWithCsrf("/api/folder/moveFolder.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ source: sourceFolder, destination: dropFolder }) - }) - .then(safeJson) - .then(data => { - if (data && !data.error) { - showToast(`Folder moved to ${dropFolder}!`); - // Make icons reflect new emptiness without reload -refreshFolderIcon(dragData.sourceFolder); -refreshFolderIcon(dropFolder); + // 5) first TOP-LEVEL child with any viewable descendant + for (const name of topNames) { + const child = effectiveRoot === 'root' ? name : `${effectiveRoot}/${name}`; + if (await hasUnlockedDescendant(child, 2)) return child; + } + } catch {} - if (window.currentFolder && - (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) { - const base = sourceFolder.split("/").pop(); - const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base; - - // carry color without await - const oldColor = window.folderColorMap[sourceFolder]; - if (oldColor) { - saveFolderColor(newPath, oldColor) - .then(() => saveFolderColor(sourceFolder, '')) - .catch(() => { }); - } - - window.currentFolder = newPath; - } - - return loadFolderTree().then(() => { - try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { } - loadFileList(window.currentFolder || "root"); - }); - } else { - showToast("Error: " + (data && data.error || "Could not move folder"), 5000); - } - }) - .catch(err => { - console.error("Error moving folder:", err); - showToast("Error moving folder", 5000); - }); - - return; - } - - // File(s) drop path (unchanged) - const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); - if (filesToMove.length === 0) return; - - fetchWithCsrf("/api/file/moveFiles.php", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - source: dragData.sourceFolder, - files: filesToMove, - destination: dropFolder - }) - }) - .then(safeJson) - .then(data => { - if (data.success) { - showToast(`File(s) moved successfully to ${dropFolder}!`); - loadFileList(dragData.sourceFolder); - refreshFolderIcon(dragData.sourceFolder); - refreshFolderIcon(dropFolder); - } else { - showToast("Error moving files: " + (data.error || "Unknown error")); - } - }) - .catch(error => { - console.error("Error moving files via drop on breadcrumb:", error); - showToast("Error moving files."); - }); + // 6) fallback: BFS + return await findFirstAccessibleFolder(effectiveRoot); } -// ---- Folder Colors (state + helpers) ---- -window.folderColorMap = {}; // { "path": "#RRGGBB", ... } +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)); +} +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)}&t=${Date.now()}`; + 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 invalidateFolderCaches(folder) { + if (!folder) return; + _folderCountCache.delete(folder); + _nonEmptyCache.delete(folder); + _inflightCounts.delete(folder); + _childCache.delete(folder); +} -async function loadFolderColors() { +// Expand root -> ... -> parent chain for a target folder and persist that state +async function expandAncestors(targetFolder) { try { - const r = await fetch('/api/folder/getFolderColors.php', { credentials: 'include' }); - if (!r.ok) return (window.folderColorMap = {}); - window.folderColorMap = await r.json() || {}; - } catch { window.folderColorMap = {}; } + // Always expand root first + if (!targetFolder || targetFolder === 'root') return; + + // (rest of the function unchanged) + const st = loadFolderTreeState(); + st['root'] = 'block'; + saveFolderTreeState(st); + const rootUl = getULForFolder('root'); + if (rootUl) { + rootUl.classList.add('expanded'); rootUl.classList.remove('collapsed'); + const rr = document.getElementById('rootRow'); + if (rr) rr.setAttribute('aria-expanded', 'true'); + await ensureChildrenLoaded('root', rootUl); + } + + const parts = String(targetFolder || '').split('/').filter(Boolean); + // we only need to expand up to the parent of the leaf + const parents = parts.slice(0, -1); + let acc = ''; + const newState = loadFolderTreeState(); + for (let i = 0; i < parents.length; i++) { + acc = (i === 0) ? parents[0] : `${acc}/${parents[i]}`; + const ul = getULForFolder(acc); + if (!ul) continue; + ul.classList.add('expanded'); ul.classList.remove('collapsed'); + const li = document.querySelector(`.folder-option[data-folder="${CSS.escape(acc)}"]`)?.closest('li[role="treeitem"]'); + if (li) li.setAttribute('aria-expanded', 'true'); + newState[acc] = 'block'; + await ensureChildrenLoaded(acc, ul); + } + saveFolderTreeState(newState); + } catch {} } -// tiny color utils +/* ---------------------- + SVG icon helpers +----------------------*/ +export function folderSVG(kind = 'empty', { locked = false } = {}) { + const gid = 'g' + Math.random().toString(36).slice(2, 8); + return ` +`; +} +function setFolderIconForOption(optEl, kind) { + const iconEl = optEl.querySelector('.folder-icon'); + if (!iconEl) return; + const isLocked = optEl.classList.contains('locked'); + iconEl.dataset.kind = kind; + iconEl.innerHTML = folderSVG(kind, { locked: isLocked }); +} +export function refreshFolderIcon(folder) { + invalidateFolderCaches(folder); + ensureFolderIcon(folder); +} +function ensureFolderIcon(folder) { + const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`); + if (!opt) return; + + setFolderIconForOption(opt, 'empty'); + + Promise.all([ + fetchFolderCounts(folder).catch(() => ({ folders: 0, files: 0 })), + peekHasFolders(folder).catch(() => false) + ]).then(([cnt, hasKids]) => { + const folders = Number(cnt?.folders || 0); + const files = Number(cnt?.files || 0); + const hasAny = (folders + files) > 0; + + setFolderIconForOption(opt, hasAny ? 'paper' : 'empty'); + updateToggleForOption(folder, !!hasKids || folders > 0); + }).catch(() => {}); +} + +/* ---------------------- + Toggle (chevron) helper +----------------------*/ +function updateToggleForOption(folder, hasChildren) { + const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`); + if (!opt) return; + const row = opt.closest('.folder-row'); + if (!row) return; + + let btn = row.querySelector('button.folder-toggle'); + let spacer = row.querySelector('.folder-spacer'); + + if (hasChildren) { + if (!btn) { + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'folder-toggle'; + btn.setAttribute('data-folder', folder); + btn.setAttribute('aria-label', 'Expand'); + if (spacer) spacer.replaceWith(btn); + else row.insertBefore(btn, opt); + } + } else { + if (btn) { + const newSpacer = document.createElement('span'); + newSpacer.className = 'folder-spacer'; + newSpacer.setAttribute('aria-hidden', 'true'); + btn.replaceWith(newSpacer); + } else if (!spacer) { + spacer = document.createElement('span'); + spacer.className = 'folder-spacer'; + spacer.setAttribute('aria-hidden', 'true'); + row.insertBefore(spacer, opt); + } + } +} + +/* ---------------------- + Colors +----------------------*/ +window.folderColorMap = window.folderColorMap || {}; function hexToHsl(hex) { hex = hex.replace('#', ''); if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); @@ -320,10 +628,10 @@ function hslToHex(h, s, l) { }; return '#' + f(0) + f(8) + f(4); } -function lighten(hex, amt = 12) { +function lighten(hex, amt = 14) { const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.min(100, l + amt)); } -function darken(hex, amt = 18) { +function darken(hex, amt = 22) { const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(0, l - amt)); } @@ -343,8 +651,8 @@ function applyFolderColorToOption(folder, hex) { } const front = hex; // main - const back = lighten(hex, 14); // body (slightly lighter) - const stroke = darken(hex, 22); // outline + const back = lighten(hex, 14); // body (slightly lighter) + const stroke = darken(hex, 22); // outline el.style.setProperty('--filr-folder-front', front); el.style.setProperty('--filr-folder-back', back); @@ -367,17 +675,190 @@ async function saveFolderColor(folder, colorHexOrEmpty) { if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`); // update local map & apply if (data.color) window.folderColorMap[folder] = data.color; -else delete window.folderColorMap[folder]; -applyFolderColorToOption(folder, data.color || ''); + else delete window.folderColorMap[folder]; + applyFolderColorToOption(folder, data.color || ''); -// notify other views (fileListView's strip) -window.dispatchEvent(new CustomEvent('folderColorChanged', { - detail: { folder, color: data.color || '' } -})); + // notify other views (fileListView's strip) + window.dispatchEvent(new CustomEvent('folderColorChanged', { + detail: { folder, color: data.color || '' } + })); -return data; + return data; } +async function loadFolderColors() { + try { + const r = await fetch('/api/folder/getFolderColors.php', { credentials: 'include' }); + if (!r.ok) { window.folderColorMap = {}; return; } + window.folderColorMap = await r.json() || {}; + } catch { window.folderColorMap = {}; } +} + +/* ---------------------- + Expansion state migration on move/rename +----------------------*/ +function migrateExpansionStateOnMove(sourceFolder, newPath, ensureOpenParents = []) { + const st = loadFolderTreeState(); + const keys = Object.keys(st); + const next = { ...st }; + let changed = false; + + for (const k of keys) { + if (k === sourceFolder || k.startsWith(sourceFolder + '/')) { + const suffix = k.slice(sourceFolder.length); + delete next[k]; + next[newPath + suffix] = st[k]; // carry same 'block'/'none' + changed = true; + } + } + // keep destination parents open to show the moved node + ensureOpenParents.forEach(p => { if (p) next[p] = 'block'; }); + + if (changed || ensureOpenParents.length) saveFolderTreeState(next); +} + +/* ---------------------- + Fetch children (lazy) +----------------------*/ +async function fetchChildrenOnce(folder) { + if (_childCache.has(folder)) return _childCache.get(folder); + const qs = new URLSearchParams({ folder }); + qs.set('limit', String(PAGE_LIMIT)); + const res = await fetch(`/api/folder/listChildren.php?${qs.toString()}`, { method: 'GET', credentials: 'include' }); + const body = await safeJson(res); + + const raw = Array.isArray(body.items) ? body.items : []; + const items = raw + .map(normalizeItem) + .filter(Boolean) + .filter(it => { + const s = it.name.toLowerCase(); + return s !== 'trash' && s !== 'profile_pics'; + }); + + const payload = { items, nextCursor: body.nextCursor ?? null }; + _childCache.set(folder, payload); + return payload; +} +async function loadMoreChildren(folder, ulEl, moreLi) { + const cached = _childCache.get(folder); + const cursor = cached?.nextCursor || null; + + const qs = new URLSearchParams({ folder }); + if (cursor) qs.set('cursor', cursor); + qs.set('limit', String(PAGE_LIMIT)); + + const res = await fetch(`/api/folder/listChildren.php?${qs.toString()}`, { method: 'GET', credentials: 'include' }); + const body = await safeJson(res); + + const raw = Array.isArray(body.items) ? body.items : []; + const newItems = raw + .map(normalizeItem) + .filter(Boolean) + .filter(it => { + const s = it.name.toLowerCase(); + return s !== 'trash' && s !== 'profile_pics'; + }); + + const nextCursor = body.nextCursor ?? null; + + newItems.forEach(it => { + const li = makeChildLi(folder, it); + ulEl.insertBefore(li, moreLi); + const full = (folder === 'root') ? it.name : `${folder}/${it.name}`; + try { applyFolderColorToOption(full, (window.folderColorMap||{})[full] || ''); } catch {} + ensureFolderIcon(full); + }); + + const merged = (cached?.items || []).concat(newItems); + if (nextCursor) _childCache.set(folder, { items: merged, nextCursor }); + else { + moreLi.remove(); + _childCache.set(folder, { items: merged, nextCursor: null }); + } + + primeChildToggles(ulEl); + const hasKids = !!ulEl.querySelector(':scope > li.folder-item'); + updateToggleForOption(folder, hasKids); +} +async function ensureChildrenLoaded(folder, ulEl) { + const cached = _childCache.get(folder); + let items, nextCursor; + if (cached) { items = cached.items; nextCursor = cached.nextCursor; } + else { + const res = await fetchChildrenOnce(folder); + items = res.items; nextCursor = res.nextCursor; _childCache.set(folder, { items, nextCursor }); + } + + if (!ulEl._renderedOnce) { + items.forEach(it => { + const li = makeChildLi(folder, it); + ulEl.appendChild(li); + const full = (folder === 'root') ? it.name : `${folder}/${it.name}`; + try { applyFolderColorToOption(full, (window.folderColorMap||{})[full] || ''); } catch {} + ensureFolderIcon(full); + }); + ulEl._renderedOnce = true; + } + + let moreLi = ulEl.querySelector('.load-more'); + if (nextCursor && !moreLi) { + moreLi = document.createElement('li'); + moreLi.className = 'load-more'; + moreLi.innerHTML = ``; + moreLi.querySelector('button')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + const prev = btn.textContent; + btn.disabled = true; + btn.setAttribute('aria-busy', 'true'); + btn.textContent = (t('loading') || 'Loading…'); + try { + await loadMoreChildren(folder, ulEl, moreLi); + } finally { + // If moreLi still exists (not removed because we reached the end), restore + if (document.body.contains(moreLi)) { + btn.disabled = false; + btn.removeAttribute('aria-busy'); + btn.textContent = (t('load_more') || 'Load more'); + } + } + }); + ulEl.appendChild(moreLi); + } else if (!nextCursor && moreLi) { + moreLi.remove(); + } + + + primeChildToggles(ulEl); + const hasKidsNow = !!ulEl.querySelector(':scope > li.folder-item'); + updateToggleForOption(folder, hasKidsNow); + peekHasFolders(folder).then(h => { try { updateToggleForOption(folder, !!h); } catch {} }); +} + +/* ---------------------- + Prime icons/chevrons for a UL +----------------------*/ +function primeChildToggles(ulEl) { + ulEl.querySelectorAll('.folder-option[data-folder]').forEach(opt => { + const f = opt.dataset.folder; + try { setFolderIconForOption(opt, 'empty'); } catch {} + + Promise.all([ + fetchFolderCounts(f).catch(() => ({ folders: 0, files: 0 })), + peekHasFolders(f).catch(() => false) + ]).then(([cnt, hasKids]) => { + const folders = Number(cnt?.folders || 0); + const files = Number(cnt?.files || 0); + const hasAny = (folders + files) > 0; + + try { setFolderIconForOption(opt, hasAny ? 'paper' : 'empty'); } catch {} + // IMPORTANT: chevron is true if EITHER we have subfolders (peek) OR counts say so + try { updateToggleForOption(f, !!hasKids || folders > 0); } catch {} + }); + }); +} + + export function openColorFolderModal(folder) { const existing = window.folderColorMap[folder] || ''; const defaultHex = existing || '#f6b84e'; @@ -394,11 +875,12 @@ export function openColorFolderModal(folder) { margin-top:12px; padding:10px 12px; border-radius:12px; border:1px solid var(--border-color, #ddd); background: var(--bg, transparent); + flex-wrap: wrap; } body.dark-mode #colorFolderModal .folder-preview { --border-color:#444; --bg: rgba(255,255,255,.02); } - #colorFolderModal .folder-preview .folder-icon { width:56px; height:56px; display:inline-block } + #colorFolderModal .folder-preview .folder-icon { width:56px; height:56px; display:inline-block; flex: 0 0 56px; } #colorFolderModal .folder-preview svg { width:56px; height:56px; display:block } /* Use the same variable names you already apply on folder rows */ #colorFolderModal .folder-preview .folder-back { fill:var(--filr-folder-back, #f0d084) } @@ -407,7 +889,15 @@ export function openColorFolderModal(folder) { #colorFolderModal .folder-preview .paper { fill:#fff; stroke:#d0d0d0; stroke-width:.6 } #colorFolderModal .folder-preview .paper-fold { fill:#ececec } #colorFolderModal .folder-preview .paper-line { stroke:#c8c8c8; stroke-width:.8 } - #colorFolderModal .folder-preview .label { font-weight:600; user-select:none } + #colorFolderModal .folder-preview .label { + font-weight:600; user-select:none; + max-width: calc(100% - 70px); + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + line-height: 1.25; + font-size: clamp(12px, 2.4vw, 16px); + } /* High-contrast ghost button just for this modal */ #colorFolderModal .btn-ghost { @@ -431,8 +921,8 @@ export function openColorFolderModal(folder) { } -