Files
FileRise/public/js/folderManager.js

2188 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 listChildrens 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 thats 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">&times;</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; dont 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 were allowed. If the node isnt 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 nodes 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();