2188 lines
82 KiB
JavaScript
2188 lines
82 KiB
JavaScript
// 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 = `
|
||
<div class="empty-state" style="padding:20px; text-align:center; opacity:.9;">
|
||
${t('no_access') || 'You do not have access to this resource.'}
|
||
</div>
|
||
`;
|
||
}
|
||
/* ----------------------
|
||
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 `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" style="display:block;shape-rendering:geometricPrecision">
|
||
<defs>
|
||
<clipPath id="${gid}-clipBack"><path d="M3.5 7.5 H10.5 L12.5 9.5 H20.5 C21.6 9.5 22.5 10.4 22.5 11.5 V19.5 C22.5 20.6 21.6 21.5 20.5 21.5 H5.5 C4.4 21.5 3.5 20.6 3.5 19.5 V9.5 C3.5 8.4 4.4 7.5 5.5 7.5 Z"/></clipPath>
|
||
<clipPath id="${gid}-clipFront"><path d="M2.5 10.5 H11.5 L13.5 8.5 H20.5 C21.6 8.5 22.5 9.4 22.5 10.5 V17.5 C22.5 18.6 21.6 19.5 20.5 19.5 H4.5 C3.4 19.5 2.5 18.6 2.5 17.5 V10.5 Z"/></clipPath>
|
||
<linearGradient id="${gid}-back" x1="4" y1="20" x2="20" y2="4" gradientUnits="userSpaceOnUse">
|
||
<stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".55" stop-color="#fff" stop-opacity=".10"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||
</linearGradient>
|
||
<linearGradient id="${gid}-front" x1="6" y1="19" x2="19" y2="7" gradientUnits="userSpaceOnUse">
|
||
<stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".06"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<g class="back-group" clip-path="url(#${gid}-clipBack)">
|
||
<path class="folder-back" d="M3.5 7.5 H10.5 L12.5 9.5 H20.5 C21.6 9.5 22.5 10.4 22.5 11.5 V19.5 C22.5 20.6 21.6 21.5 20.5 21.5 H5.5 C4.4 21.5 3.5 20.6 3.5 19.5 V9.5 C3.5 8.4 4.4 7.5 5.5 7.5 Z"/>
|
||
<path d="M3.5 7.5 H10.5 L12.5 9.5 H20.5 V21.5 H3.5 Z" fill="url(#${gid}-back)" pointer-events="none"/>
|
||
</g>
|
||
${kind === 'paper' ? `
|
||
<g class="paper-group" transform="translate(0, -1.2)">
|
||
<rect class="paper" x="6.5" y="6.5" width="11" height="10" rx="1"/>
|
||
<path class="paper-fold" d="M17.5 6.5 H15.2 L17.5 9.0 Z"/>
|
||
<g transform="translate(0, -2.4)">
|
||
<path class="paper-ink" d="M9 11.3 H14.2" stroke="#4da3ff" stroke-width=".9" fill="none" stroke-linecap="round" stroke-linejoin="round" paint-order="normal" vector-effect="non-scaling-stroke"/>
|
||
<path class="paper-ink" d="M9 12.8 H16.4" stroke="#4da3ff" stroke-width=".9" fill="none" stroke-linecap="round" stroke-linejoin="round" paint-order="normal" vector-effect="non-scaling-stroke"/>
|
||
</g>
|
||
</g>` : ``}
|
||
<g class="front-group" clip-path="url(#${gid}-clipFront)">
|
||
<path class="folder-front" d="M2.5 10.5 H11.5 L13.5 8.5 H20.5 C21.6 8.5 22.5 9.4 22.5 10.5 V17.5 C22.5 18.6 21.6 19.5 20.5 19.5 H4.5 C3.4 19.5 2.5 18.6 2.5 17.5 V10.5 Z"/>
|
||
<path d="M2.5 10.5 H11.5 L13.5 8.5 H20.5 V19.5 H2.5 Z" fill="url(#${gid}-front)" pointer-events="none"/>
|
||
</g>
|
||
|
||
${locked ? `
|
||
<!-- Small padlock, positioned on the folder front, non-interactive -->
|
||
<g class="lock-overlay" transform="translate(14.6, 10.6)" pointer-events="none">
|
||
<path class="lock-shackle" d="M1.9 3 V2.2 C1.9 1.2 2.8 0.3 3.8 0.3 C4.8 0.3 5.7 1.2 5.7 2.2 V3"/>
|
||
<rect class="lock-body" x="0" y="3" width="7.6" height="5.6" rx="1.2"></rect>
|
||
<circle class="lock-keyhole" cx="3.8" cy="6" r="0.7"></circle>
|
||
</g>` : ``}
|
||
|
||
<path class="lip-highlight" d="M3 10.5 H11.5 L13.5 8.5 H20.3"/>
|
||
</svg>`;
|
||
}
|
||
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 = `
|
||
<div class="modal-content" style="width:460px;max-width:90vw;">
|
||
<style>
|
||
/* Scoped styles for the preview only */
|
||
#colorFolderModal .folder-preview {
|
||
display:flex; align-items:center; gap:12px;
|
||
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; 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) }
|
||
#colorFolderModal .folder-preview .folder-front { fill:var(--filr-folder-front, #e2b158); stroke:var(--filr-folder-stroke, #996a1e); stroke-width:.6 }
|
||
#colorFolderModal .folder-preview .lip-highlight { stroke:rgba(255,255,255,.35); fill:none; stroke-width:.9 }
|
||
#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;
|
||
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 {
|
||
background: transparent;
|
||
border: 1px solid var(--ghost-border, #cfcfcf);
|
||
color: var(--ghost-fg, #222);
|
||
padding: 6px 12px;
|
||
}
|
||
#colorFolderModal .btn-ghost:hover {
|
||
background: var(--ghost-hover-bg, #f5f5f5);
|
||
}
|
||
#colorFolderModal .btn-ghost:focus-visible {
|
||
outline: 2px solid #8ab4f8;
|
||
outline-offset: 2px;
|
||
}
|
||
body.dark-mode #colorFolderModal .btn-ghost {
|
||
--ghost-border: #60636b;
|
||
--ghost-fg: #f0f0f0;
|
||
--ghost-hover-bg: rgba(255,255,255,.08);
|
||
}
|
||
</style>
|
||
|
||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
|
||
<h3 style="margin:0;flex:1;min-width:0;white-space:normal;overflow-wrap:anywhere;font-size:clamp(14px,2.6vw,18px)">${t('color_folder')}: ${escapeHTML(folder)}</h3>
|
||
<span id="closeColorFolderModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||
</div>
|
||
|
||
<div class="modal-body" style="margin-top:10px;">
|
||
<label for="folderColorInput" style="display:block;margin-bottom:6px;">${t('choose_color')}</label>
|
||
<input type="color" id="folderColorInput" style="width:100%;padding:6px;" value="${defaultHex}"/>
|
||
|
||
<!-- Live preview -->
|
||
<div class="folder-preview" id="folderColorPreview" aria-label="Preview">
|
||
<span class="folder-icon" aria-hidden="true">${folderSVG('paper')}</span>
|
||
<span class="label">${escapeHTML(folder)}</span>
|
||
</div>
|
||
|
||
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end;">
|
||
<button id="resetFolderColorBtn" class="btn btn-ghost">${t('reset_default')}</button>
|
||
<button id="saveFolderColorBtn" class="btn btn-primary">${t('save_color')}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
function addFolderActionButton(rowEl, folderPath) {
|
||
if (!rowEl || !folderPath) return;
|
||
if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
// share styling with file list kebab
|
||
btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
|
||
btn.textContent = 'more_vert';
|
||
|
||
const label = t('folder_actions') || 'Folder actions';
|
||
btn.title = label;
|
||
btn.setAttribute('aria-label', label);
|
||
|
||
// only control visibility/layout here; let CSS handle colors/hover
|
||
Object.assign(btn.style, {
|
||
display: 'none',
|
||
marginLeft: '4px',
|
||
flexShrink: '0'
|
||
});
|
||
|
||
btn.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const rect = btn.getBoundingClientRect();
|
||
const x = rect.right;
|
||
const y = rect.bottom;
|
||
const opt = rowEl.querySelector('.folder-option');
|
||
await openFolderActionsMenu(folderPath, opt, x, y);
|
||
});
|
||
|
||
rowEl.appendChild(btn);
|
||
}
|
||
|
||
/* ----------------------
|
||
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');
|
||
}
|
||
|
||
// <li class="folder-item" role="treeitem" aria-expanded="false">
|
||
const li = document.createElement('li');
|
||
li.className = 'folder-item';
|
||
li.setAttribute('role', 'treeitem');
|
||
li.setAttribute('aria-expanded', 'false');
|
||
|
||
// <div class="folder-row">
|
||
const row = document.createElement('div');
|
||
row.className = 'folder-row';
|
||
|
||
// <span class="folder-spacer" aria-hidden="true"></span>
|
||
const spacer = document.createElement('span');
|
||
spacer.className = 'folder-spacer';
|
||
spacer.setAttribute('aria-hidden', 'true');
|
||
|
||
// <span class="folder-option[ locked]" [draggable]>
|
||
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;
|
||
|
||
// <span class="folder-icon" aria-hidden="true" data-kind="empty">[svg]</span>
|
||
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 });
|
||
|
||
// <span class="folder-label">name</span>
|
||
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);
|
||
|
||
// Add 3-dot actions button for unlocked folders
|
||
if (!locked) addFolderActionButton(row, fullPath);
|
||
|
||
li.append(row);
|
||
|
||
// <ul class="folder-tree collapsed" role="group"></ul>
|
||
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;
|
||
}
|
||
|
||
function updateFolderActionButtons() {
|
||
const container = document.getElementById('folderTreeContainer');
|
||
if (!container) return;
|
||
|
||
// Hide all kebabs by default
|
||
container.querySelectorAll('.folder-kebab').forEach(btn => {
|
||
btn.style.display = 'none';
|
||
});
|
||
|
||
// Show only for the currently selected, unlocked folder
|
||
const selectedOpt = container.querySelector('.folder-option.selected');
|
||
if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
|
||
|
||
const row = selectedOpt.closest('.folder-row');
|
||
if (!row) return;
|
||
const kebab = row.querySelector('.folder-kebab');
|
||
if (kebab) {
|
||
kebab.style.display = 'inline-flex';
|
||
}
|
||
}
|
||
|
||
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 {}
|
||
}
|
||
|
||
// Keep the 3-dot action aligned to the active folder
|
||
updateFolderActionButtons();
|
||
}
|
||
|
||
/* ----------------------
|
||
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 = `
|
||
<div id="rootRow" class="folder-row" role="treeitem" aria-expanded="${String(rootOpen)}">
|
||
<button type="button" class="folder-toggle" data-folder="${effectiveRoot}" aria-label="${rootOpen ? 'Collapse' : 'Expand'}"></button>
|
||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">
|
||
<span class="folder-icon" aria-hidden="true" data-kind="empty">${folderSVG('empty')}</span>
|
||
<span class="folder-label">${escapeHTML(effectiveLabel)}</span>
|
||
</span>
|
||
</div>
|
||
<ul id="rootChildren" class="folder-tree ${rootOpen ? 'expanded' : 'collapsed'}" role="group"></ul>
|
||
`;
|
||
container.innerHTML = html;
|
||
|
||
// Add 3-dot actions button for root
|
||
const rootRow = document.getElementById('rootRow');
|
||
if (rootRow) {
|
||
addFolderActionButton(rootRow, effectiveRoot);
|
||
}
|
||
|
||
// 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: <button.mi><i.material-icons/><span/>)
|
||
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 openFolderActionsMenu(folder, targetEl, clientX, clientY) {
|
||
if (!folder) return;
|
||
|
||
window.currentFolder = folder;
|
||
await applyFolderCapabilities(folder);
|
||
|
||
// Clear previous selection in tree + breadcrumb
|
||
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
|
||
|
||
// Mark the clicked thing selected (folder-option or breadcrumb)
|
||
if (targetEl) targetEl.classList.add('selected');
|
||
|
||
// Also sync selection in the tree if we invoked from a breadcrumb or kebab
|
||
const tree = document.getElementById('folderTreeContainer');
|
||
if (tree) {
|
||
const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||
if (inTree) inTree.classList.add('selected');
|
||
}
|
||
|
||
// Show the kebab only for this selected folder
|
||
updateFolderActionButtons();
|
||
|
||
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(clientX, clientY, menuItems);
|
||
}
|
||
|
||
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 (no menu)
|
||
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;
|
||
|
||
const x = e.clientX;
|
||
const y = e.clientY;
|
||
await openFolderActionsMenu(folder, target, x, y);
|
||
}
|
||
|
||
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();
|