// 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}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
function detachFolderModalsToBody() {
const ids = [
'createFolderModal',
'deleteFolderModal',
'moveFolderModal',
'renameFolderModal',
];
ids.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.parentNode !== document.body) {
document.body.appendChild(el);
}
if (!el.style.zIndex) {
el.style.zIndex = '13000';
}
});
}
document.addEventListener('DOMContentLoaded', detachFolderModalsToBody);
const PAGE_LIMIT = 100;
/* ----------------------
Helpers: safe JSON + state
----------------------*/
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 err = new Error(msg);
err.status = res.status;
throw err;
}
return body ?? {};
}
function disableAllFolderControls() {
['createFolderBtn','moveFolderBtn','renameFolderBtn','colorFolderBtn','deleteFolderBtn','shareFolderBtn']
.forEach(id => setControlEnabled(document.getElementById(id), false));
}
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";
return indent + parts[parts.length - 1];
}
return folder;
}
export function getParentFolder(folder) {
if (folder === "root") return "root";
const lastSlash = folder.lastIndexOf("/");
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
}
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;
el.classList.toggle('disabled', !enabled);
el.setAttribute('aria-disabled', String(!enabled));
el.style.pointerEvents = enabled ? '' : 'none';
el.style.opacity = enabled ? '' : '0.5';
}
async function applyFolderCapabilities(folder) {
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; }
}
/**
* 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' ||
lower.startsWith('resumable_')
) {
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';
// --- Always start with "Root" crumb ---
const rootSpan = document.createElement('span');
rootSpan.className = 'breadcrumb-link';
rootSpan.dataset.folder = 'root';
rootSpan.textContent = 'root';
frag.appendChild(rootSpan);
if (path === 'root') {
// You are in root: just "Root"
return frag;
}
// Separator after Root
let sep = document.createElement('span');
sep.className = 'file-breadcrumb-sep';
sep.textContent = '›';
frag.appendChild(sep);
// Now add the rest of the path normally (folder1, folder1/subA, etc.)
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) {
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) return;
container.removeEventListener("click", breadcrumbClickHandler);
container.removeEventListener("dragover", breadcrumbDragOverHandler);
container.removeEventListener("dragleave", breadcrumbDragLeaveHandler);
container.removeEventListener("drop", breadcrumbDropHandler);
container.addEventListener("click", breadcrumbClickHandler);
container.addEventListener("dragover", breadcrumbDragOverHandler);
container.addEventListener("dragleave", breadcrumbDragLeaveHandler);
container.addEventListener("drop", breadcrumbDropHandler);
}
async function breadcrumbClickHandler(e) {
const link = e.target.closest(".breadcrumb-link");
if (!link) return;
e.stopPropagation();
e.preventDefault();
const folder = link.dataset.folder;
await selectFolder(folder); // will toast + bail if not allowed
}
function breadcrumbDragOverHandler(e) {
const link = e.target.closest(".breadcrumb-link");
if (!link) return;
e.preventDefault();
link.classList.add("drop-hover");
}
function breadcrumbDragLeaveHandler(e) {
const link = e.target.closest(".breadcrumb-link");
if (!link) return;
link.classList.remove("drop-hover");
}
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);
}
/* ----------------------
Folder-only scope (server truthy)
----------------------*/
async function checkUserFolderPermission() {
const username = localStorage.getItem("username") || "";
try {
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;
}
}
/* ----------------------
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}
// --- 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;
}
// 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 {}
// 6) fallback: BFS
return await findFirstAccessibleFolder(effectiveRoot);
}
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);
}
// Expand root -> ... -> parent chain for a target folder and persist that state
async function expandAncestors(targetFolder) {
try {
// 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 {}
}
/* ----------------------
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('');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
function hslToHex(h, s, l) {
h /= 360; s /= 100; l /= 100;
const f = n => {
const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l);
const c = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
return Math.round(255 * c).toString(16).padStart(2, '0');
};
return '#' + f(0) + f(8) + f(4);
}
function lighten(hex, amt = 14) {
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.min(100, l + amt));
}
function darken(hex, amt = 22) {
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(0, l - amt));
}
function applyFolderColorToOption(folder, hex) {
// accepts folder like "root" or "A/B"
const sel = folder === 'root'
? '#rootRow .folder-option'
: `.folder-option[data-folder="${CSS.escape(folder)}"]`;
const el = document.querySelector(sel);
if (!el) return;
if (!hex) {
el.style.removeProperty('--filr-folder-front');
el.style.removeProperty('--filr-folder-back');
el.style.removeProperty('--filr-folder-stroke');
return;
}
const front = hex; // main
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);
el.style.setProperty('--filr-folder-stroke', stroke);
}
function applyAllFolderColors(scope = document) {
Object.entries(window.folderColorMap || {}).forEach(([folder, hex]) => {
applyFolderColorToOption(folder, hex);
});
}
async function saveFolderColor(folder, colorHexOrEmpty) {
const res = await fetch('/api/folder/saveFolderColor.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
body: JSON.stringify({ folder, color: colorHexOrEmpty })
});
const data = await res.json().catch(() => ({}));
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 || '');
// notify other views (fileListView's strip)
window.dispatchEvent(new CustomEvent('folderColorChanged', {
detail: { folder, color: data.color || '' }
}));
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' &&
!s.startsWith('resumable_')
);
});
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' &&
!s.startsWith('resumable_');
});
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';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-ghost';
btn.textContent = t('load_more') || 'Load more';
btn.setAttribute('aria-label', t('load_more') || 'Load more');
if (ulEl.id) btn.setAttribute('aria-controls', ulEl.id);
btn.addEventListener('click', async (e) => {
const b = e.currentTarget;
const prevText = b.textContent;
b.disabled = true;
b.setAttribute('aria-busy', 'true');
b.textContent = t('loading') || 'Loading…';
try {
await loadMoreChildren(folder, ulEl, moreLi);
} finally {
// If the "load more" node still exists (wasn't removed because we reached end),
// restore the button state.
if (moreLi.isConnected) {
b.disabled = false;
b.removeAttribute('aria-busy');
b.textContent = t('load_more') || 'Load more';
}
}
});
moreLi.appendChild(btn);
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';
const modal = document.createElement('div');
modal.id = 'colorFolderModal';
modal.className = 'modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// --- live preview wiring
const previewEl = modal.querySelector('#folderColorPreview');
const inputEl = modal.querySelector('#folderColorInput');
function applyPreview(hex) {
if (!hex || typeof hex !== 'string') return;
const front = hex;
const back = lighten(hex, 14);
const stroke = darken(hex, 22);
previewEl.style.setProperty('--filr-folder-front', front);
previewEl.style.setProperty('--filr-folder-back', back);
previewEl.style.setProperty('--filr-folder-stroke', stroke);
}
applyPreview(defaultHex);
inputEl?.addEventListener('input', () => applyPreview(inputEl.value));
// --- buttons/close
document.getElementById('closeColorFolderModal')?.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
});
document.getElementById('resetFolderColorBtn')?.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
await saveFolderColor(folder, ''); // clear
showToast(t('folder_color_cleared'));
} catch (err) {
showToast(err.message || 'Error');
} finally {
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
}
});
document.getElementById('saveFolderColorBtn')?.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
const hex = String(inputEl.value || '').trim();
await saveFolderColor(folder, hex);
showToast(t('folder_color_saved'));
} finally {
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
}
});
}
/* ----------------------
DOM builders & DnD
----------------------*/
function isSafeFolderPath(p) {
// Client-side defense-in-depth; server already enforces safe segments.
// Allows letters/numbers/space/_-. and slashes between segments.
return /^(root|(?!\.)[^/\0]+)(\/(?!\.)[^/\0]+)*$/.test(String(p || ''));
}
function makeChildLi(parentPath, item) {
const it = normalizeItem(item);
if (!it) return document.createElement('li');
const { name, locked } = it;
const fullPath = parentPath === 'root' ? name : `${parentPath}/${name}`;
if (!isSafeFolderPath(fullPath)) {
// Fail closed if something looks odd; don’t render a clickable node.
return document.createElement('li');
}
//
const li = document.createElement('li');
li.className = 'folder-item';
li.setAttribute('role', 'treeitem');
li.setAttribute('aria-expanded', 'false');
//
const row = document.createElement('div');
row.className = 'folder-row';
//
const spacer = document.createElement('span');
spacer.className = 'folder-spacer';
spacer.setAttribute('aria-hidden', 'true');
//
const opt = document.createElement('span');
opt.className = 'folder-option' + (locked ? ' locked' : '');
if (!locked) opt.setAttribute('draggable', 'true');
// Use dataset instead of attribute string interpolation.
opt.dataset.folder = fullPath;
// [svg]
const icon = document.createElement('span');
icon.className = 'folder-icon';
icon.setAttribute('aria-hidden', 'true');
icon.dataset.kind = 'empty';
// Safe: SVG is generated locally, not from user input.
// nosemgrep: javascript.browser.security.dom-xss.innerhtml
icon.innerHTML = folderSVG('empty', { locked });
// name
const label = document.createElement('span');
label.className = 'folder-label';
// Critical: never innerHTML here — textContent avoids XSS.
label.textContent = name;
opt.append(icon, label);
row.append(spacer, opt);
li.append(row);
//
const ul = document.createElement('ul');
ul.className = 'folder-tree collapsed';
ul.setAttribute('role', 'group');
li.append(ul);
// Wire DnD / click the same as before
if (!locked) {
opt.addEventListener('dragstart', (ev) => {
try { ev.dataTransfer.setData('application/x-filerise-folder', fullPath); } catch {}
try { ev.dataTransfer.setData('text/plain', fullPath); } catch {}
ev.dataTransfer.effectAllowed = 'move';
});
opt.addEventListener('dragover', folderDragOverHandler);
opt.addEventListener('dragleave', folderDragLeaveHandler);
opt.addEventListener('drop', (e) => handleDropOnFolder(e, fullPath));
opt.addEventListener('click', () => selectFolder(fullPath));
} else {
opt.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
if (!ul) return;
const willExpand = !ul.classList.contains('expanded');
ul.classList.toggle('expanded', willExpand);
ul.classList.toggle('collapsed', !willExpand);
li.setAttribute('aria-expanded', String(willExpand));
const st = loadFolderTreeState(); st[fullPath] = willExpand ? 'block' : 'none'; saveFolderTreeState(st);
if (willExpand) await ensureChildrenLoaded(fullPath, ul);
});
}
return li;
}
function folderDragOverHandler(event) { event.preventDefault(); event.currentTarget.classList.add("drop-hover"); }
function folderDragLeaveHandler(event) { event.currentTarget.classList.remove("drop-hover"); }
/* ----------------------
Color-carry helper (fix #2)
----------------------*/
async function carryFolderColor(sourceFolder, newPath) {
const oldColor = window.folderColorMap[sourceFolder];
if (!oldColor) return;
try {
await saveFolderColor(newPath, oldColor);
await saveFolderColor(sourceFolder, '');
} catch {}
}
/* ----------------------
Handle drop (files or folders)
----------------------*/
function handleDropOnFolder(event, dropFolder) {
event.preventDefault();
event.currentTarget?.classList?.remove("drop-hover");
let dragData = null;
try {
const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr);
} catch { /* noop */ }
// FOLDER->FOLDER move fallback
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
const sourceFolder = String(plain || "").trim();
if (!sourceFolder || sourceFolder === "root") return;
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
showToast("Invalid destination.", 4000); return;
}
// snapshot current expansion state (to re-apply later)
const preState = loadFolderTreeState();
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
}).then(safeJson).then(async (data) => {
if (data && !data.error) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
// carry color
await carryFolderColor(sourceFolder, newPath);
// migrate expansion + keep dest open
migrateExpansionStateOnMove(sourceFolder, newPath, [dropFolder, getParentFolder(dropFolder)]);
// refresh parents (incremental)
const srcParent = getParentFolder(sourceFolder);
const dstParent = dropFolder;
invalidateFolderCaches(srcParent);
invalidateFolderCaches(dstParent);
clearPeekCache([srcParent, dstParent, sourceFolder, newPath]);
const srcUl = getULForFolder(srcParent);
const dstUl = getULForFolder(dstParent);
if (srcUl) { srcUl._renderedOnce = false; srcUl.innerHTML = ""; await ensureChildrenLoaded(srcParent, srcUl); }
if (dstUl) { dstUl._renderedOnce = false; dstUl.innerHTML = ""; await ensureChildrenLoaded(dstParent, dstUl); }
// destination now definitely has a child folder → force chevron immediately
updateToggleForOption(dstParent, true);
ensureFolderIcon(dstParent);
// source may have lost its last child folder → recompute from the live DOM
const _srcUlLive = getULForFolder(srcParent);
updateToggleForOption(srcParent, !!(_srcUlLive && _srcUlLive.querySelector(':scope > li.folder-item')));
// re-apply all saved expansions so nothing "closes"
await expandAndLoadSavedState();
// update selection/current folder (if you were inside moved subtree)
if (window.currentFolder) {
if (window.currentFolder === sourceFolder) {
window.currentFolder = newPath;
} else if (window.currentFolder.startsWith(sourceFolder + "/")) {
const suffix = window.currentFolder.slice(sourceFolder.length); // includes leading '/'
window.currentFolder = newPath + suffix;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder);
}
// icons + breadcrumb + file list
refreshFolderIcon(srcParent);
refreshFolderIcon(dstParent);
showToast(`Folder moved to ${dropFolder}!`);
updateBreadcrumbTitle(window.currentFolder || newPath);
loadFileList(window.currentFolder || newPath);
// ensure the moved node is visible & selected
selectFolder(window.currentFolder || newPath);
} 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) move
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}!`);
refreshFolderIcon(dragData.sourceFolder);
refreshFolderIcon(dropFolder);
loadFileList(dragData.sourceFolder);
} else {
showToast("Error moving files: " + (data.error || "Unknown error"));
}
}).catch(() => showToast("Error moving files."));
}
/* ----------------------
Selection + helpers
----------------------*/
function getULForFolder(folder) {
if (folder === 'root') return document.getElementById('rootChildren');
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
const li = opt ? opt.closest('li[role="treeitem"]') : null;
return li ? li.querySelector(':scope > ul.folder-tree') : null;
}
async function selectFolder(selected) {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
// If the node is in the tree, trust its locked class.
let opt = container.querySelector(`.folder-option[data-folder="${CSS.escape(selected)}"]`);
let allowed = true;
applyFolderCapabilities(selected);
if (opt && opt.classList.contains('locked')) {
allowed = false;
} else if (!opt) {
// Not in DOM → preflight capabilities so breadcrumbs (and other callers)
// can't jump into forbidden folders.
try {
allowed = await canViewFolder(selected);
} catch {
allowed = false;
}
}
if (!allowed) {
showToast(t('no_access') || "You do not have access to this resource.");
return; // do NOT change currentFolder or lastOpenedFolder
}
// At this point we’re allowed. If the node isn’t visible yet, open its parents
// so the tree reflects where we are going.
if (!opt && selected && selected !== 'root') {
const parts = selected.split('/').filter(Boolean);
const st = loadFolderTreeState();
let acc = '';
for (let i = 0; i < parts.length; i++) {
acc = i === 0 ? parts[i] : `${acc}/${parts[i]}`;
st[acc] = 'block';
}
saveFolderTreeState(st);
// Materialize the opened branches
await expandAndLoadSavedState();
opt = container.querySelector(`.folder-option[data-folder="${CSS.escape(selected)}"]`);
}
// Visual selection
container.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
if (opt) opt.classList.add("selected");
// Update state + UI
window.currentFolder = selected;
localStorage.setItem("lastOpenedFolder", selected);
updateBreadcrumbTitle(selected);
applyFolderCapabilities(selected);
ensureFolderIcon(selected);
loadFileList(selected);
// Expand the selected node’s UL if present
const ul = getULForFolder(selected);
if (ul) {
ul.classList.add('expanded');
ul.classList.remove('collapsed');
const parentLi = selected === 'root'
? document.getElementById('rootRow')
: (opt ? opt.closest('li[role="treeitem"]') : null);
if (parentLi) parentLi.setAttribute('aria-expanded', 'true');
const st = loadFolderTreeState();
st[selected] = 'block';
saveFolderTreeState(st);
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
}
}
/* ----------------------
Expand saved state at boot
----------------------*/
async function expandAndLoadSavedState() {
const st = loadFolderTreeState();
const openKeys = Object.keys(st).filter(k => st[k] === 'block');
openKeys.sort((a, b) => a.split('/').length - b.split('/').length);
for (const key of openKeys) {
const ul = getULForFolder(key);
if (!ul) continue;
ul.classList.add('expanded');
ul.classList.remove('collapsed');
let li;
if (key === 'root') {
li = document.getElementById('rootRow');
} else {
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(key)}"]`);
li = opt ? opt.closest('li[role="treeitem"]') : null;
}
if (li) li.setAttribute('aria-expanded', 'true');
try { await ensureChildrenLoaded(key, ul); } catch {}
}
}
/* ----------------------
Main: loadFolderTree
----------------------*/
export async function loadFolderTree(selectedFolder) {
try {
await checkUserFolderPermission();
const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root";
let effectiveLabel = "(Root)";
if (window.userFolderOnly && username) {
effectiveRoot = username;
effectiveLabel = `(Root)`;
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
} else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
const container = document.getElementById("folderTreeContainer");
if (!container) return;
const state0 = loadFolderTreeState();
const rootOpen = state0[effectiveRoot] !== 'none';
let html = `
${folderSVG('empty')}
${escapeHTML(effectiveLabel)}
`;
container.innerHTML = html;
// Determine root's lock state
const rootOpt = container.querySelector('.root-folder-option');
let rootLocked = false;
try {
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(effectiveRoot)}`, { credentials: 'include' });
if (res.ok) {
const caps = await res.json();
const canView = !!(caps.canView ?? caps.canRead ?? caps.canReadOwn ?? caps.isAdmin);
rootLocked = !canView;
}
} catch {}
if (rootOpt && rootLocked) markOptionLocked(rootOpt, true);
applyFolderCapabilities(effectiveRoot);
// Root DnD + prime icon/chevron
{
const ro = rootOpt;
if (ro) {
const isLocked = ro.classList.contains('locked');
if (!isLocked) {
ro.addEventListener('dragover', folderDragOverHandler);
ro.addEventListener('dragleave', folderDragLeaveHandler);
ro.addEventListener('drop', (e) => handleDropOnFolder(e, effectiveRoot));
}
try { setFolderIconForOption(ro, 'empty'); } catch {}
fetchFolderCounts(effectiveRoot).then(({ folders, files }) => {
const hasAny = (folders + files) > 0;
try { setFolderIconForOption(ro, hasAny ? 'paper' : 'empty'); } catch {}
return peekHasFolders(effectiveRoot).then(hasKids => {
try { updateToggleForOption(effectiveRoot, !!hasKids || folders > 0); } catch {}
});
}).catch(() => {});
}
}
// Delegated toggle
if (!container._toggleBound) {
container._toggleBound = true;
container.addEventListener('click', async (e) => {
if (performance.now() < _suppressToggleUntil) { e.stopPropagation(); e.preventDefault(); return; }
const btn = e.target.closest('button.folder-toggle');
if (!btn || !container.contains(btn)) return;
e.stopPropagation();
const folderPath = btn.getAttribute('data-folder');
const ul = getULForFolder(folderPath);
if (!ul) return;
const willExpand = !ul.classList.contains('expanded');
ul.classList.toggle('expanded', willExpand);
ul.classList.toggle('collapsed', !willExpand);
const li = folderPath === 'root'
? document.getElementById('rootRow')
: (document.querySelector(`.folder-option[data-folder="${CSS.escape(folderPath)}"]`)?.closest('li[role="treeitem"]'));
if (li) li.setAttribute('aria-expanded', String(willExpand));
const st = loadFolderTreeState(); st[folderPath] = willExpand ? 'block' : 'none'; saveFolderTreeState(st);
if (willExpand) await ensureChildrenLoaded(folderPath, ul);
}, true);
// Delegated folder-option click
container.addEventListener("click", function(e) {
const opt = e.target.closest(".folder-option");
if (!opt || !container.contains(opt)) return;
e.stopPropagation();
if (opt.classList.contains('locked')) {
// Toggle expansion, don't select
const folderPath = opt.getAttribute('data-folder');
const ul = getULForFolder(folderPath);
if (!ul) return;
const willExpand = !ul.classList.contains('expanded');
ul.classList.toggle('expanded', willExpand);
ul.classList.toggle('collapsed', !willExpand);
const li = opt.closest('li[role="treeitem"]');
if (li) li.setAttribute('aria-expanded', String(willExpand));
const st = loadFolderTreeState(); st[folderPath] = willExpand ? 'block' : 'none'; saveFolderTreeState(st);
if (willExpand) ensureChildrenLoaded(folderPath, ul);
return;
}
selectFolder(opt.getAttribute("data-folder") || 'root');
});
}
await loadFolderColors();
applyAllFolderColors(container);
// Root: load its children if open
const rc = document.getElementById('rootChildren');
if (rc && rootOpen) {
await ensureChildrenLoaded(effectiveRoot, rc);
primeChildToggles(rc);
const hasKids = !!rc.querySelector('li.folder-item');
updateToggleForOption(effectiveRoot, hasKids);
}
// Expand + render all previously opened nodes
await expandAndLoadSavedState();
// ---------- Smart initial selection (sticky + top-level preference) ----------
let target = await chooseInitialFolder(effectiveRoot, selectedFolder);
if (!target) {
const ro = document.querySelector('.root-folder-option');
if (ro) ro.classList.add('selected');
// Show explicit no_access in the files pane
showNoAccessEmptyState();
applyFolderCapabilities(effectiveRoot);
showToast(t('no_access') || "You do not have access to this resource.");
return;
}
// Ensure the path to target is visibly open in the tree (even if ancestors are locked)
await expandAncestors(target);
// Persist and select
localStorage.setItem("lastOpenedFolder", target);
selectFolder(target);
// ---------------------------------------------------------------------------
// --------------------------------------------
} catch (err) {
console.error("Error loading folder tree:", err);
if (err.status === 403) showToast("You don't have permission to view folders.");
}
}
export function loadFolderList(selectedFolder) { loadFolderTree(selectedFolder); } // compat
/* ----------------------
Context menu (file-menu look)
----------------------*/
function iconForFolderLabel(lbl) {
if (lbl === t('create_folder')) return 'create_new_folder';
if (lbl === t('move_folder')) return 'drive_file_move';
if (lbl === t('rename_folder')) return 'drive_file_rename_outline';
if (lbl === t('color_folder')) return 'palette';
if (lbl === t('folder_share')) return 'share';
if (lbl === t('delete_folder')) return 'delete';
return 'more_horiz';
}
function getFolderMenu() {
let m = document.getElementById('folderManagerContextMenu');
if (!m) {
m = document.createElement('div');
m.id = 'folderManagerContextMenu';
m.className = 'filr-menu';
m.setAttribute('role', 'menu');
// position + scroll are inline so it works even before CSS loads
m.style.position = 'fixed';
m.style.minWidth = '180px';
m.style.maxHeight = '420px';
m.style.overflowY = 'auto';
m.hidden = true;
// Close on outside click / Esc
document.addEventListener('click', (ev) => {
if (!m.hidden && !m.contains(ev.target)) hideFolderManagerContextMenu();
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') hideFolderManagerContextMenu();
});
document.body.appendChild(m);
}
return m;
}
export function showFolderManagerContextMenu(x, y, menuItems) {
const menu = getFolderMenu();
menu.innerHTML = '';
// Build items (same DOM as file menu: )
menuItems.forEach((item, idx) => {
// optional separator after first item (like file menu top block)
if (idx === 1) {
const sep = document.createElement('div');
sep.className = 'sep';
menu.appendChild(sep);
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mi';
btn.setAttribute('role', 'menuitem');
const ic = document.createElement('i');
ic.className = 'material-icons';
ic.textContent = iconForFolderLabel(item.label);
const tx = document.createElement('span');
tx.textContent = item.label;
btn.append(ic, tx);
btn.addEventListener('click', (e) => {
e.stopPropagation();
hideFolderManagerContextMenu(); // close first so it never overlays your modal
try { item.action && item.action(); } catch (err) { console.error(err); }
});
menu.appendChild(btn);
});
// Show + clamp to viewport
menu.hidden = false;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const r = menu.getBoundingClientRect();
let nx = r.left, ny = r.top;
if (r.right > window.innerWidth) nx -= (r.right - window.innerWidth + 6);
if (r.bottom > window.innerHeight) ny -= (r.bottom - window.innerHeight + 6);
menu.style.left = `${Math.max(6, nx)}px`;
menu.style.top = `${Math.max(6, ny)}px`;
}
export function hideFolderManagerContextMenu() {
const menu = document.getElementById('folderManagerContextMenu');
if (menu) menu.hidden = true;
}
async function folderManagerContextMenuHandler(e) {
const target = e.target.closest('.folder-option, .breadcrumb-link');
if (!target) return;
e.preventDefault();
e.stopPropagation();
// Toggle-only for locked nodes
if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder);
if (ul) {
const willExpand = !ul.classList.contains('expanded');
ul.classList.toggle('expanded', willExpand);
ul.classList.toggle('collapsed', !willExpand);
const li = target.closest('li[role="treeitem"]');
if (li) li.setAttribute('aria-expanded', String(willExpand));
const st = loadFolderTreeState(); st[folder] = willExpand ? 'block' : 'none'; saveFolderTreeState(st);
if (willExpand) ensureChildrenLoaded(folder, ul);
}
return;
}
const folder = target.getAttribute('data-folder');
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
target.classList.add('selected');
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
const menuItems = [
{ label: t('create_folder'), action: () => {
const modal = document.getElementById('createFolderModal');
const input = document.getElementById('newFolderName');
if (modal) modal.style.display = 'block';
if (input) input.focus();
}},
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
];
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
}
function bindFolderManagerContextMenu() {
const tree = document.getElementById('folderTreeContainer');
if (tree) {
if (tree._ctxHandler) tree.removeEventListener('contextmenu', tree._ctxHandler, false);
tree._ctxHandler = (e) => {
const onOption = e.target.closest('.folder-option');
if (!onOption) return;
folderManagerContextMenuHandler(e);
};
tree.addEventListener('contextmenu', tree._ctxHandler, false);
}
const title = document.getElementById('fileListTitle');
if (title) {
if (title._ctxHandler) title.removeEventListener('contextmenu', title._ctxHandler, false);
title._ctxHandler = (e) => {
const onCrumb = e.target.closest('.breadcrumb-link');
if (!onCrumb) return;
folderManagerContextMenuHandler(e);
};
title.addEventListener('contextmenu', title._ctxHandler, false);
}
}
// document.addEventListener("click", hideFolderManagerContextMenu); // not needed anymore; handled above
/* ----------------------
Rename / Delete / Create hooks
----------------------*/
export function openRenameFolderModal() {
detachFolderModalsToBody();
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to rename."); return; }
const parts = selectedFolder.split("/");
const input = document.getElementById("newRenameFolderName");
const modal = document.getElementById("renameFolderModal");
if (!input || !modal) return;
input.value = parts[parts.length - 1];
modal.style.display = "block";
setTimeout(() => { input.focus(); input.select(); }, 100);
}
const cancelRename = document.getElementById("cancelRenameFolder");
if (cancelRename) cancelRename.addEventListener("click", function () {
const modal = document.getElementById("renameFolderModal");
const input = document.getElementById("newRenameFolderName");
if (modal) modal.style.display = "none";
if (input) input.value = "";
});
attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
const submitRename = document.getElementById("submitRenameFolder");
if (submitRename) submitRename.addEventListener("click", function (event) {
event.preventDefault();
const selectedFolder = window.currentFolder || "root";
const input = document.getElementById("newRenameFolderName");
if (!input) return;
const newNameBasename = input.value.trim();
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
showToast("Please enter a valid new folder name."); return;
}
const parentPath = getParentFolder(selectedFolder);
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
fetchWithCsrf("/api/folder/renameFolder.php", {
method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include",
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
}).then(safeJson).then(async data => {
if (data.success) {
showToast("Folder renamed successfully!");
const oldPath = selectedFolder;
window.currentFolder = newFolderFull;
localStorage.setItem("lastOpenedFolder", newFolderFull);
// carry color on rename as well
await carryFolderColor(oldPath, newFolderFull);
// migrate expansion state like move and keep parent open
migrateExpansionStateOnMove(oldPath, newFolderFull, [parentPath]);
// refresh parent list incrementally (preserves other branches)
const parent = parentPath;
invalidateFolderCaches(parent);
clearPeekCache([parent, oldPath, newFolderFull]);
const ul = getULForFolder(parent);
if (ul) { ul._renderedOnce = false; ul.innerHTML = ""; await ensureChildrenLoaded(parent, ul); }
// restore any open nodes we had saved
await expandAndLoadSavedState();
// re-select the renamed node
selectFolder(newFolderFull);
} else {
showToast("Error: " + (data.error || "Could not rename folder"));
}
}).catch(err => console.error("Error renaming folder:", err)).finally(() => {
const modal = document.getElementById("renameFolderModal");
const input2 = document.getElementById("newRenameFolderName");
if (modal) modal.style.display = "none";
if (input2) input2.value = "";
});
});
export function openDeleteFolderModal() {
detachFolderModalsToBody();
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to delete."); return; }
const msgEl = document.getElementById("deleteFolderMessage");
const modal = document.getElementById("deleteFolderModal");
if (!msgEl || !modal) return;
msgEl.textContent = "Are you sure you want to delete folder " + selectedFolder + "?";
modal.style.display = "block";
}
const cancelDelete = document.getElementById("cancelDeleteFolder");
if (cancelDelete) cancelDelete.addEventListener("click", function () {
const modal = document.getElementById("deleteFolderModal");
if (modal) modal.style.display = "none";
});
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
const confirmDelete = document.getElementById("confirmDeleteFolder");
if (confirmDelete) confirmDelete.addEventListener("click", async function () {
const selectedFolder = window.currentFolder || "root";
fetchWithCsrf("/api/folder/deleteFolder.php", {
method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include",
body: JSON.stringify({ folder: selectedFolder })
}).then(safeJson).then(async data => {
if (data.success) {
showToast("Folder deleted successfully!");
const parent = getParentFolder(selectedFolder);
window.currentFolder = parent;
localStorage.setItem("lastOpenedFolder", parent);
invalidateFolderCaches(parent);
clearPeekCache([parent, selectedFolder]);
const ul = getULForFolder(parent);
if (ul) { ul._renderedOnce = false; ul.innerHTML = ""; await ensureChildrenLoaded(parent, ul); }
selectFolder(parent);
} else {
showToast("Error: " + (data.error || "Could not delete folder"));
}
}).catch(err => console.error("Error deleting folder:", err)).finally(() => {
const modal = document.getElementById("deleteFolderModal");
if (modal) modal.style.display = "none";
});
});
const createBtn = document.getElementById("createFolderBtn");
if (createBtn) createBtn.addEventListener("click", function () {
detachFolderModalsToBody();
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
if (input) input.focus();
});
const cancelCreate = document.getElementById("cancelCreateFolder");
if (cancelCreate) cancelCreate.addEventListener("click", function () {
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "none";
if (input) input.value = "";
});
attachEnterKeyListener("createFolderModal", "submitCreateFolder");
const submitCreate = document.getElementById("submitCreateFolder");
if (submitCreate) submitCreate.addEventListener("click", async () => {
const input = document.getElementById("newFolderName");
const folderInput = input ? input.value.trim() : "";
if (!folderInput) return showToast("Please enter a folder name.");
const selectedFolder = window.currentFolder || "root";
const parent = selectedFolder === "root" ? "" : selectedFolder;
try { await loadCsrfToken(); } catch { return showToast("Could not refresh CSRF token. Please reload."); }
fetchWithCsrf("/api/folder/createFolder.php", {
method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include",
body: JSON.stringify({ folderName: folderInput, parent })
}).then(safeJson).then(async data => {
if (!data.success) throw new Error(data.error || "Server rejected the request");
showToast("Folder created!");
const parentFolder = parent || 'root';
const parentUL = getULForFolder(parentFolder);
const full = parent ? `${parent}/${folderInput}` : folderInput;
if (parentUL) {
const li = makeChildLi(parentFolder, folderInput);
const moreLi = parentUL.querySelector('.load-more');
parentUL.insertBefore(li, moreLi || null);
try { applyFolderColorToOption(full, (window.folderColorMap || {})[full] || ''); } catch {}
const opt = li.querySelector('.folder-option');
if (opt) setFolderIconForOption(opt, 'empty');
ensureFolderIcon(full);
updateToggleForOption(parentFolder, true);
invalidateFolderCaches(parentFolder);
clearPeekCache([parentFolder, full]);
}
window.currentFolder = full;
localStorage.setItem("lastOpenedFolder", full);
selectFolder(full);
}).catch(e => showToast("Error creating folder: " + e.message)).finally(() => {
const modal = document.getElementById("createFolderModal");
const input2 = document.getElementById("newFolderName");
if (modal) modal.style.display = "none";
if (input2) input2.value = "";
});
});
/* ----------------------
Move (modal) + Color carry + State migration as well
----------------------*/
export function openMoveFolderUI(sourceFolder) {
detachFolderModalsToBody();
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;
if (targetSel) {
targetSel.innerHTML = '';
fetch('/api/folder/getFolderList.php', { credentials: 'include' }).then(r => r.json()).then(list => {
if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) list = list.map(it => it.folder);
const rootOpt = document.createElement('option'); rootOpt.value = 'root'; rootOpt.textContent = '(Root)'; targetSel.appendChild(rootOpt);
(list || []).filter(f => f && f !== 'trash' && f !== (window.currentFolder || '')).forEach(f => {
const o = document.createElement('option'); o.value = f; o.textContent = f; targetSel.appendChild(o);
});
}).catch(() => {});
}
if (modal) modal.style.display = 'block';
}
document.addEventListener("DOMContentLoaded", () => {
const moveBtn = document.getElementById('moveFolderBtn');
const modal = document.getElementById('moveFolderModal');
const targetSel = document.getElementById('moveFolderTarget');
const cancelBtn = document.getElementById('cancelMoveFolder');
const confirmBtn = document.getElementById('confirmMoveFolder');
if (moveBtn) moveBtn.addEventListener('click', () => {
const cf = window.currentFolder || 'root';
if (!cf || cf === 'root') { showToast('Select a non-root folder to move.'); return; }
openMoveFolderUI(cf);
});
if (cancelBtn) cancelBtn.addEventListener('click', () => { if (modal) modal.style.display = 'none'; });
if (confirmBtn) confirmBtn.addEventListener('click', async () => {
if (!targetSel) return;
const destination = targetSel.value;
const source = window.currentFolder;
if (!destination) { showToast('Pick a destination'); return; }
if (destination === source || (destination + '/').startsWith(source + '/')) {
showToast('Invalid destination'); return;
}
// snapshot expansion before move
const preState = loadFolderTreeState();
try {
const res = await fetch('/api/folder/moveFolder.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
body: JSON.stringify({ source, destination })
});
const data = await safeJson(res);
if (res.ok && data && !data.error) {
const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base;
// carry color
await carryFolderColor(source, newPath);
// migrate expansion
migrateExpansionStateOnMove(source, newPath, [destination, getParentFolder(destination)]);
// refresh parents
const srcParent = getParentFolder(source);
const dstParent = destination;
invalidateFolderCaches(srcParent); invalidateFolderCaches(dstParent);
clearPeekCache([srcParent, dstParent, source, newPath]);
const srcUl = getULForFolder(srcParent); const dstUl = getULForFolder(dstParent);
updateToggleForOption(srcParent, !!srcUl && !!srcUl.querySelector(':scope > li.folder-item'));
if (srcUl) { srcUl._renderedOnce = false; srcUl.innerHTML = ""; await ensureChildrenLoaded(srcParent, srcUl); }
if (dstUl) { dstUl._renderedOnce = false; dstUl.innerHTML = ""; await ensureChildrenLoaded(dstParent, dstUl); }
updateToggleForOption(dstParent, true);
ensureFolderIcon(dstParent);
const _srcUlLive = getULForFolder(srcParent);
updateToggleForOption(srcParent, !!(_srcUlLive && _srcUlLive.querySelector(':scope > li.folder-item')));
// re-apply expansions
await expandAndLoadSavedState();
// update currentFolder
if (window.currentFolder === source) {
window.currentFolder = newPath;
} else if (window.currentFolder && window.currentFolder.startsWith(source + '/')) {
const suffix = window.currentFolder.slice(source.length);
window.currentFolder = newPath + suffix;
}
localStorage.setItem("lastOpenedFolder", window.currentFolder || newPath);
if (modal) modal.style.display = 'none';
refreshFolderIcon(srcParent); refreshFolderIcon(dstParent);
showToast('Folder moved');
selectFolder(window.currentFolder || newPath);
} else {
showToast('Error: ' + (data && data.error || 'Move failed'));
}
} catch (e) { console.error(e); showToast('Move failed'); }
});
});
/* ----------------------
Expand path helper
----------------------*/
function expandTreePath(path, opts = {}) {
const { force = false, persist = false, includeLeaf = false } = opts;
const state = loadFolderTreeState();
const parts = (path || '').split('/').filter(Boolean);
let cumulative = '';
const lastIndex = includeLeaf ? parts.length - 1 : Math.max(0, parts.length - 2);
parts.forEach((part, i) => {
cumulative = i === 0 ? part : `${cumulative}/${part}`;
if (i > lastIndex) return;
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
if (!option) return;
const li = option.closest('li[role="treeitem"]');
const nestedUl = li ? li.querySelector(':scope > ul') : null;
if (!nestedUl) return;
const shouldExpand = force || state[cumulative] === 'block';
nestedUl.classList.toggle('expanded', shouldExpand);
nestedUl.classList.toggle('collapsed', !shouldExpand);
li.setAttribute('aria-expanded', String(!!shouldExpand));
if (persist && shouldExpand) state[cumulative] = 'block';
});
if (persist) saveFolderTreeState(state);
}
/* ----------------------
Wire toolbar buttons that were inert (rename/delete)
----------------------*/
document.addEventListener("DOMContentLoaded", () => {
const renameBtn = document.getElementById("renameFolderBtn");
if (renameBtn) renameBtn.addEventListener("click", () => {
const cf = window.currentFolder || "root";
if (!cf || cf === "root") { showToast("Please select a valid folder to rename."); return; }
openRenameFolderModal();
});
const deleteBtn = document.getElementById("deleteFolderBtn");
if (deleteBtn) deleteBtn.addEventListener("click", () => {
const cf = window.currentFolder || "root";
if (!cf || cf === "root") { showToast("Please select a valid folder to delete."); return; }
openDeleteFolderModal();
});
});
/* ----------------------
Global key & minor binds
----------------------*/
document.addEventListener("keydown", function (e) {
const tag = e.target.tagName ? e.target.tagName.toLowerCase() : "";
if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) return;
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
if (window.currentFolder && window.currentFolder !== "root") {
e.preventDefault();
openDeleteFolderModal();
}
}
});
document.addEventListener("DOMContentLoaded", function () {
const shareFolderBtn = document.getElementById("shareFolderBtn");
if (shareFolderBtn) {
shareFolderBtn.addEventListener("click", () => {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to share."); return; }
openFolderShareModal(selectedFolder);
});
}
const colorFolderBtn = document.getElementById("colorFolderBtn");
if (colorFolderBtn) {
colorFolderBtn.addEventListener("click", () => {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { showToast(t('please_select_valid_folder') || "Please select a valid folder."); return; }
openColorFolderModal(selectedFolder);
});
}
});
// Initial context menu delegation bind
bindFolderManagerContextMenu();