// 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}}'; 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') continue; const child = (f === 'root') ? name : `${f}/${name}`; if (!visited.has(child)) q.push(child); } // If there are more pages, we only need one page to keep BFS order lightweight } catch { /* ignore and continue */ } } return null; // none found } function showNoAccessEmptyState() { const host = document.getElementById('fileListContainer') || document.getElementById('fileList') || document.querySelector('.file-list-container'); if (!host) return; // Clear whatever was there (e.g., “No Files Found”) host.innerHTML = `
${t('no_access') || 'You do not have access to this resource.'}
`; } /* ---------------------- Breadcrumb ----------------------*/ function renderBreadcrumbFragment(folderPath) { const frag = document.createDocumentFragment(); const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root'; const crumbs = path.split('/').filter(Boolean); let acc = ''; for (let i = 0; i < crumbs.length; i++) { const part = crumbs[i]; acc = (i === 0) ? part : (acc + '/' + part); const span = document.createElement('span'); span.className = 'breadcrumb-link'; span.dataset.folder = acc; span.textContent = part; frag.appendChild(span); if (i < crumbs.length - 1) { const sep = document.createElement('span'); sep.className = 'file-breadcrumb-sep'; sep.textContent = '›'; frag.appendChild(sep); } } return frag; } export function updateBreadcrumbTitle(folder) { const titleEl = document.getElementById("fileListTitle"); if (!titleEl) return; titleEl.textContent = ""; titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(renderBreadcrumbFragment(folder)); titleEl.appendChild(document.createTextNode(")")); setupBreadcrumbDelegation(); bindFolderManagerContextMenu(); } export function setupBreadcrumbDelegation() { const container = document.getElementById("fileListTitle"); if (!container) 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'; }); const payload = { items, nextCursor: body.nextCursor ?? null }; _childCache.set(folder, payload); return payload; } async function loadMoreChildren(folder, ulEl, moreLi) { const cached = _childCache.get(folder); const cursor = cached?.nextCursor || null; const qs = new URLSearchParams({ folder }); if (cursor) qs.set('cursor', cursor); qs.set('limit', String(PAGE_LIMIT)); const res = await fetch(`/api/folder/listChildren.php?${qs.toString()}`, { method: 'GET', credentials: 'include' }); const body = await safeJson(res); const raw = Array.isArray(body.items) ? body.items : []; const newItems = raw .map(normalizeItem) .filter(Boolean) .filter(it => { const s = it.name.toLowerCase(); return s !== 'trash' && s !== 'profile_pics'; }); const nextCursor = body.nextCursor ?? null; newItems.forEach(it => { const li = makeChildLi(folder, it); ulEl.insertBefore(li, moreLi); const full = (folder === 'root') ? it.name : `${folder}/${it.name}`; try { applyFolderColorToOption(full, (window.folderColorMap||{})[full] || ''); } catch {} ensureFolderIcon(full); }); const merged = (cached?.items || []).concat(newItems); if (nextCursor) _childCache.set(folder, { items: merged, nextCursor }); else { moreLi.remove(); _childCache.set(folder, { items: merged, nextCursor: null }); } primeChildToggles(ulEl); const hasKids = !!ulEl.querySelector(':scope > li.folder-item'); updateToggleForOption(folder, hasKids); } async function ensureChildrenLoaded(folder, ulEl) { const cached = _childCache.get(folder); let items, nextCursor; if (cached) { items = cached.items; nextCursor = cached.nextCursor; } else { const res = await fetchChildrenOnce(folder); items = res.items; nextCursor = res.nextCursor; _childCache.set(folder, { items, nextCursor }); } if (!ulEl._renderedOnce) { items.forEach(it => { const li = makeChildLi(folder, it); ulEl.appendChild(li); const full = (folder === 'root') ? it.name : `${folder}/${it.name}`; try { applyFolderColorToOption(full, (window.folderColorMap||{})[full] || ''); } catch {} ensureFolderIcon(full); }); ulEl._renderedOnce = true; } let moreLi = ulEl.querySelector('.load-more'); if (nextCursor && !moreLi) { moreLi = document.createElement('li'); moreLi.className = 'load-more'; moreLi.innerHTML = ``; moreLi.querySelector('button')?.addEventListener('click', async (e) => { const btn = e.currentTarget; const prev = btn.textContent; btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = (t('loading') || 'Loading…'); try { await loadMoreChildren(folder, ulEl, moreLi); } finally { // If moreLi still exists (not removed because we reached the end), restore if (document.body.contains(moreLi)) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = (t('load_more') || 'Load more'); } } }); ulEl.appendChild(moreLi); } else if (!nextCursor && moreLi) { moreLi.remove(); } primeChildToggles(ulEl); const hasKidsNow = !!ulEl.querySelector(':scope > li.folder-item'); updateToggleForOption(folder, hasKidsNow); peekHasFolders(folder).then(h => { try { updateToggleForOption(folder, !!h); } catch {} }); } /* ---------------------- Prime icons/chevrons for a UL ----------------------*/ function primeChildToggles(ulEl) { ulEl.querySelectorAll('.folder-option[data-folder]').forEach(opt => { const f = opt.dataset.folder; try { setFolderIconForOption(opt, 'empty'); } catch {} Promise.all([ fetchFolderCounts(f).catch(() => ({ folders: 0, files: 0 })), peekHasFolders(f).catch(() => false) ]).then(([cnt, hasKids]) => { const folders = Number(cnt?.folders || 0); const files = Number(cnt?.files || 0); const hasAny = (folders + files) > 0; try { setFolderIconForOption(opt, hasAny ? 'paper' : 'empty'); } catch {} // IMPORTANT: chevron is true if EITHER we have subfolders (peek) OR counts say so try { updateToggleForOption(f, !!hasKids || folders > 0); } catch {} }); }); } export function openColorFolderModal(folder) { const existing = window.folderColorMap[folder] || ''; const defaultHex = existing || '#f6b84e'; 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 makeChildLi(parentPath, item) { const it = normalizeItem(item); if (!it) return document.createElement('li'); const { name, locked } = it; const fullPath = parentPath === 'root' ? name : `${parentPath}/${name}`; const li = document.createElement('li'); li.className = 'folder-item'; li.setAttribute('role', 'treeitem'); li.setAttribute('aria-expanded', 'false'); const optClass = 'folder-option' + (locked ? ' locked' : ''); li.innerHTML = `
${escapeHTML(name)}
`; const opt = li.querySelector('.folder-option'); const ul = li.querySelector(':scope > ul.folder-tree'); // If server told us about subfolders or non-empty, apply immediately if (typeof item.hasSubfolders === 'boolean') { updateToggleForOption(fullPath, item.hasSubfolders); // Also stash on DOM so primeChildToggles can see it without refetch opt.dataset.hasSubs = item.hasSubfolders ? '1' : '0'; } if (typeof item.nonEmpty === 'boolean') { setFolderIconForOption(opt, item.nonEmpty ? 'paper' : 'empty'); opt.dataset.nonEmpty = item.nonEmpty ? '1' : '0'; } 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 = `
${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 (minimal hook) ----------------------*/ async function folderManagerContextMenuHandler(e) { const target = e.target.closest(".folder-option, .breadcrumb-link"); if (!target) return; e.preventDefault(); e.stopPropagation(); // No menu on locked folders; just toggle expansion if it is a tree node 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.pageX, e.pageY, menuItems); } export function showFolderManagerContextMenu(x, y, menuItems) { let menu = document.getElementById("folderManagerContextMenu"); if (!menu) { menu = document.createElement("div"); menu.id = "folderManagerContextMenu"; menu.style.position = "absolute"; menu.style.padding = "5px 0"; menu.style.minWidth = "150px"; menu.style.zIndex = "9999"; document.body.appendChild(menu); } if (document.body.classList.contains("dark-mode")) { menu.style.backgroundColor = "#2c2c2c"; menu.style.border = "1px solid #555"; menu.style.color = "#e0e0e0"; } else { menu.style.backgroundColor = "#fff"; menu.style.border = "1px solid #ccc"; menu.style.color = "#000"; } menu.innerHTML = ""; menuItems.forEach(item => { const it = document.createElement("div"); it.textContent = item.label; it.style.padding = "5px 15px"; it.style.cursor = "pointer"; it.addEventListener("mouseover", () => { it.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0"; }); it.addEventListener("mouseout", () => { it.style.backgroundColor = ""; }); it.addEventListener("click", () => { item.action(); hideFolderManagerContextMenu(); }); menu.appendChild(it); }); menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.display = "block"; } export function hideFolderManagerContextMenu() { const menu = document.getElementById("folderManagerContextMenu"); if (menu) menu.style.display = "none"; } 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); /* ---------------------- Rename / Delete / Create hooks ----------------------*/ export function openRenameFolderModal() { 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() { 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 () { 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) { 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();