mirror of
https://github.com/error311/FileRise.git
synced 2026-05-11 22:42:05 -05:00
75f2d6de69
- files(core): add internal authenticated file links (create/resolve token endpoints + ACL-checked deep-link open) - ui(files): add Link File in row context menu + toolbar single-selection action with copy-link modal - boot/auth: preserve fileLink return URL through login/OIDC, resolve link post-auth, focus/highlight linked file - shares: add upload-only File Request mode for shared folders with hide-listing support and dedicated drop-zone UI - shared-upload: add per-link validation/rate limits/quota tracking + safer relative-path handling for chunked uploads - admin: improve shared links listing with file-request grouping and created-by/source metadata
8668 lines
291 KiB
JavaScript
8668 lines
291 KiB
JavaScript
// fileListView.js
|
||
import {
|
||
escapeHTML,
|
||
debounce,
|
||
buildSearchAndPaginationControls,
|
||
buildFileTableHeader,
|
||
buildFileTableRow,
|
||
buildBottomControls,
|
||
updateFileActionButtons,
|
||
showToast,
|
||
updateRowHighlight,
|
||
toggleRowSelection,
|
||
attachEnterKeyListener
|
||
} from './domUtils.js?v={{APP_QVER}}';
|
||
import { withBase } from './basePath.js?v={{APP_QVER}}';
|
||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||
import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
|
||
import {
|
||
openDownloadModal,
|
||
handleCopySelected,
|
||
handleMoveSelected,
|
||
handleDeleteSelected,
|
||
handleRenameSelected
|
||
} from './fileActions.js?v={{APP_QVER}}';
|
||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||
import {
|
||
getParentFolder,
|
||
updateBreadcrumbTitle,
|
||
setupBreadcrumbDelegation,
|
||
showFolderManagerContextMenu,
|
||
hideFolderManagerContextMenu,
|
||
openRenameFolderModal,
|
||
renameFolderInline,
|
||
openDeleteFolderModal,
|
||
refreshFolderIcon,
|
||
openColorFolderModal,
|
||
openMoveFolderUI,
|
||
startInlineRenameInTree,
|
||
startFolderCryptoJobFlow,
|
||
folderSVG,
|
||
expandTreePath,
|
||
expandTreePathAsync,
|
||
loadFolderTree,
|
||
resetFolderTreeCaches,
|
||
syncFolderTreeSelection,
|
||
syncTreeAfterFolderMove
|
||
} from './folderManager.js?v={{APP_QVER}}';
|
||
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
|
||
import {
|
||
folderDragOverHandler,
|
||
folderDragLeaveHandler,
|
||
folderDropHandler
|
||
} from './fileDragDrop.js?v={{APP_QVER}}';
|
||
import { runTransferJob } from './transferJobs.js?v={{APP_QVER}}';
|
||
|
||
export let fileData = [];
|
||
export let sortOrder = { column: "modified", ascending: false };
|
||
|
||
let searchEverywhereBtn = null;
|
||
let searchEverywhereModal = null;
|
||
let searchEverywhereCard = null;
|
||
let searchEverywhereResultsEl = null;
|
||
let searchEverywhereInputEl = null;
|
||
let searchEverywhereLimitEl = null;
|
||
let searchEverywhereSourceEl = null;
|
||
let searchEverywhereStatusEl = null;
|
||
let searchEverywhereListEl = null;
|
||
let searchEverywhereRunId = 0;
|
||
let pendingSearchSelection = null;
|
||
|
||
try {
|
||
window.addEventListener('folderCapsUpdated', (e) => {
|
||
const d = (e && e.detail) || {};
|
||
const folder = d.folder || window.currentFolder || 'root';
|
||
// Banner is only relevant for the currently viewed folder, but icons in the list may reference
|
||
// other folders (e.g. decrypting from a parent folder view), so always refresh icons.
|
||
if (folder === (window.currentFolder || 'root')) {
|
||
updateEncryptedFolderBanner(folder);
|
||
applyEncryptedFolderUiRestrictions();
|
||
}
|
||
|
||
// Best-effort: update icons for this specific folder if it's visible in the list/strip.
|
||
try {
|
||
const stripItem = document.querySelector(`#folderStripContainer .folder-item[data-folder="${CSS.escape(folder)}"]`);
|
||
if (stripItem) attachStripIconAsync(stripItem, folder, 48);
|
||
} catch (e2) { /* ignore */ }
|
||
try {
|
||
const row = document.querySelector(`#fileList tr.folder-row[data-folder="${CSS.escape(folder)}"]`);
|
||
if (row) attachStripIconAsync(row, folder, 28);
|
||
} catch (e3) { /* ignore */ }
|
||
|
||
refreshEncryptedFolderIconsInList();
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
|
||
function ensureEncryptedFolderBanner() {
|
||
const titleEl = document.getElementById('fileListTitle');
|
||
if (!titleEl) return null;
|
||
let el = document.getElementById('frEncryptedFolderBanner');
|
||
if (el) {
|
||
titleEl.insertAdjacentElement('afterend', el);
|
||
return el;
|
||
}
|
||
|
||
el = document.createElement('div');
|
||
el.id = 'frEncryptedFolderBanner';
|
||
el.className = 'fr-enc-banner';
|
||
el.style.display = 'none';
|
||
titleEl.insertAdjacentElement('afterend', el);
|
||
return el;
|
||
}
|
||
|
||
function updateEncryptedFolderBanner(folder) {
|
||
const el = ensureEncryptedFolderBanner();
|
||
if (!el) return;
|
||
|
||
const enc = window.currentFolderCaps?.encryption || null;
|
||
const isEncrypted = !!enc?.encrypted;
|
||
if (!isEncrypted) {
|
||
el.style.display = 'none';
|
||
el.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const inherited = !!enc?.inherited;
|
||
const root = (enc && enc.root) ? String(enc.root) : (folder || 'root');
|
||
const rootLabel = inherited && root ? ` (inherited from ${root})` : '';
|
||
|
||
el.style.display = 'flex';
|
||
el.textContent = '';
|
||
|
||
const pill = document.createElement('span');
|
||
pill.className = 'fr-enc-pill';
|
||
pill.textContent = 'Encrypted';
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'fr-enc-text';
|
||
text.textContent =
|
||
`This folder${rootLabel} is encrypted. ` +
|
||
'Video/audio previews, WebDAV, ONLYOFFICE, and archive create/extract are disabled.';
|
||
|
||
el.append(pill, text);
|
||
}
|
||
|
||
function applyEncryptedFolderUiRestrictions() {
|
||
const caps = window.currentFolderCaps || null;
|
||
const inEncrypted = !!(caps && caps.encryption && caps.encryption.encrypted);
|
||
const allowShare = !inEncrypted && (caps ? !!(caps.canShareFile || caps.canShare) : true);
|
||
|
||
try {
|
||
document.querySelectorAll('#fileList .share-btn').forEach(btn => {
|
||
btn.style.display = allowShare ? '' : 'none';
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function isEncryptedForFolderIcon(folder) {
|
||
try {
|
||
const enc = window.currentFolderCaps?.encryption || null;
|
||
if (enc && enc.encrypted) return true;
|
||
} catch (e) { /* ignore */ }
|
||
try {
|
||
const el = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||
return !!(el && el.classList.contains('encrypted'));
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function refreshEncryptedFolderIconsInList() {
|
||
// Folder strip icons
|
||
try {
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) {
|
||
strip.querySelectorAll('.folder-item').forEach(item => {
|
||
const folder = item.getAttribute('data-folder') || '';
|
||
if (!folder) return;
|
||
const iconSpan = item.querySelector('.folder-svg');
|
||
if (!iconSpan) return;
|
||
const kind = iconSpan.dataset.kind || 'empty';
|
||
iconSpan.innerHTML = folderSVG(kind, { encrypted: isEncryptedForFolderIcon(folder) });
|
||
});
|
||
}
|
||
} catch (e) { /* best effort */ }
|
||
|
||
// Inline folder rows
|
||
try {
|
||
document.querySelectorAll('#fileList tr.folder-row[data-folder], #fileListSecondary tr.folder-row[data-folder]').forEach(row => {
|
||
const folder = row.getAttribute('data-folder') || '';
|
||
if (!folder) return;
|
||
const iconSpan = row.querySelector('.folder-svg');
|
||
if (!iconSpan) return;
|
||
const kind = iconSpan.dataset.kind || 'empty';
|
||
iconSpan.innerHTML = folderSVG(kind, { encrypted: isEncryptedForFolderIcon(folder) });
|
||
});
|
||
} catch (e) { /* best effort */ }
|
||
}
|
||
|
||
function decodeHtmlEntities(str) {
|
||
if (!str) return "";
|
||
try {
|
||
const input = String(str);
|
||
return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, code) => {
|
||
if (!code) return match;
|
||
if (code[0] === '#') {
|
||
const isHex = code[1] === 'x' || code[1] === 'X';
|
||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||
if (!Number.isFinite(num)) return match;
|
||
try { return String.fromCodePoint(num); } catch (e) { return match; }
|
||
}
|
||
const table = {
|
||
amp: '&',
|
||
lt: '<',
|
||
gt: '>',
|
||
quot: '"',
|
||
apos: "'",
|
||
nbsp: '\u00A0',
|
||
colon: ':',
|
||
slash: '/',
|
||
backslash: '\\'
|
||
};
|
||
const lower = code.toLowerCase();
|
||
return Object.prototype.hasOwnProperty.call(table, lower) ? table[lower] : match;
|
||
});
|
||
} catch (e) {
|
||
return String(str);
|
||
}
|
||
}
|
||
|
||
function compareSemverLite(a, b) {
|
||
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
|
||
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
||
const len = Math.max(pa.length, pb.length);
|
||
for (let i = 0; i < len; i++) {
|
||
const na = pa[i] || 0;
|
||
const nb = pb[i] || 0;
|
||
if (na > nb) return 1;
|
||
if (na < nb) return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
|
||
const FOLDER_STRIP_PAGE_SIZE = 50;
|
||
const SLOW_LOAD_TOAST_DELAY_MS = 8000;
|
||
const SLOW_LOAD_TOAST_COOLDOWN_MS = 30000;
|
||
let _lastSlowLoadToastAt = 0;
|
||
// onnlyoffice
|
||
let OO_ENABLED = false;
|
||
let OO_EXTS = new Set();
|
||
|
||
// Late-import safety: many legacy modules bind on DOMContentLoaded/load.
|
||
// If this file is imported after those events already fired, replay them once
|
||
// so modal buttons (delete/move/rename/etc.) wire up correctly.
|
||
(function ensureLegacyReadyReplay() {
|
||
if (window.__FR_READY_REPLAYED__) return;
|
||
if (document.readyState === 'loading') return; // native event still pending
|
||
window.__FR_READY_REPLAYED__ = true;
|
||
try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (e) { }
|
||
try { window.dispatchEvent(new Event('load')); } catch (e) { }
|
||
})();
|
||
|
||
export async function initOnlyOfficeCaps() {
|
||
if (window.__FR_OO_PROMISE) return window.__FR_OO_PROMISE;
|
||
window.__FR_OO_PROMISE = (async () => {
|
||
try {
|
||
const r = await fetch(withBase('/api/onlyoffice/status.php'), { credentials: 'include' });
|
||
if (!r.ok) throw 0;
|
||
const j = await r.json();
|
||
OO_ENABLED = !!j.enabled;
|
||
OO_EXTS = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||
} catch (e) {
|
||
OO_ENABLED = false;
|
||
OO_EXTS = new Set();
|
||
} finally {
|
||
window.__FR_OO_PROMISE = null;
|
||
}
|
||
})();
|
||
return window.__FR_OO_PROMISE;
|
||
}
|
||
|
||
function wireFolderStripItems(strip, sourceId = '') {
|
||
if (!strip) return;
|
||
const paneSourceId = sourceId || getActivePaneSourceId();
|
||
|
||
// Click / DnD / context menu
|
||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||
// 1) click to navigate
|
||
el.addEventListener("click", () => {
|
||
const dest = el.dataset.folder;
|
||
if (!dest) return;
|
||
|
||
setCurrentFolderContext(dest, { resetPage: true });
|
||
|
||
document.querySelectorAll(".folder-option.selected")
|
||
.forEach(o => o.classList.remove("selected"));
|
||
document
|
||
.querySelector(`.folder-option[data-folder="${dest}"]`)
|
||
?.classList.add("selected");
|
||
|
||
loadFileList(dest);
|
||
});
|
||
|
||
// 2) drag & drop
|
||
el.addEventListener("dragover", folderDragOverHandler);
|
||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||
el.addEventListener("drop", folderDropHandler);
|
||
|
||
// 3) right-click context menu
|
||
el.addEventListener("contextmenu", async e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const dest = el.dataset.folder;
|
||
if (!dest) return;
|
||
|
||
strip.querySelectorAll(".folder-item.selected")
|
||
.forEach(i => i.classList.remove("selected"));
|
||
el.classList.add("selected");
|
||
|
||
let caps = null;
|
||
try {
|
||
caps = await fetchFolderCaps(dest, paneSourceId);
|
||
} catch (e2) { /* ignore */ }
|
||
const enc = (caps && caps.encryption) ? caps.encryption : {};
|
||
const canEncrypt = !!enc.canEncrypt;
|
||
const canDecrypt = !!enc.canDecrypt;
|
||
|
||
const menuItems = [
|
||
{
|
||
label: t("create_folder"),
|
||
action: () => {
|
||
// Create inside this folder (without changing the current view yet)
|
||
window.currentFolder = dest;
|
||
const modal = document.getElementById("createFolderModal");
|
||
if (modal) modal.style.display = "block";
|
||
const input = document.getElementById("newFolderName");
|
||
if (input) input.focus();
|
||
}
|
||
},
|
||
{
|
||
label: t("move_folder"),
|
||
action: () => openMoveFolderUI(dest)
|
||
},
|
||
{
|
||
label: t("rename_folder"),
|
||
action: () => {
|
||
window.currentFolder = dest;
|
||
openRenameFolderModal();
|
||
}
|
||
},
|
||
{
|
||
label: t("color_folder"),
|
||
action: () => openColorFolderModal(dest)
|
||
},
|
||
...(canEncrypt ? [{
|
||
label: 'Encrypt folder',
|
||
icon: 'lock',
|
||
action: () => startFolderCryptoJobFlow(dest, 'encrypt')
|
||
}] : []),
|
||
...(canDecrypt ? [{
|
||
label: 'Decrypt folder',
|
||
icon: 'lock_open',
|
||
action: () => startFolderCryptoJobFlow(dest, 'decrypt')
|
||
}] : []),
|
||
{
|
||
label: t("folder_share"),
|
||
action: () => openFolderShareModal(dest)
|
||
},
|
||
{
|
||
label: t("delete_folder"),
|
||
action: () => {
|
||
window.currentFolder = dest;
|
||
openDeleteFolderModal();
|
||
}
|
||
}
|
||
];
|
||
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
|
||
|
||
// Defensive: some browsers/styles occasionally blank the SVG after a contextmenu.
|
||
// If it happens, repaint just this icon.
|
||
try {
|
||
queueMicrotask(() => {
|
||
const iconSpan = el.querySelector('.folder-svg');
|
||
if (iconSpan && String(iconSpan.innerHTML || '').trim() === '') {
|
||
attachStripIconAsync(el, dest, 48, { sourceId: paneSourceId });
|
||
}
|
||
});
|
||
} catch (e2) { /* ignore */ }
|
||
});
|
||
});
|
||
|
||
// Close menu when clicking elsewhere
|
||
document.removeEventListener("click", hideFolderManagerContextMenu);
|
||
document.addEventListener("click", hideFolderManagerContextMenu);
|
||
|
||
// Folder icons
|
||
strip.querySelectorAll(".folder-item").forEach(el => {
|
||
const full = el.getAttribute('data-folder');
|
||
if (full) attachStripIconAsync(el, full, 48, { sourceId: paneSourceId });
|
||
});
|
||
}
|
||
|
||
function renderFolderStripPaged(strip, subfolders, sourceId = '') {
|
||
if (!strip) return;
|
||
|
||
if (!window.showFoldersInList || !subfolders.length) {
|
||
strip.style.display = "none";
|
||
strip.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const total = subfolders.length;
|
||
const pageSize = FOLDER_STRIP_PAGE_SIZE;
|
||
const totalPages = Math.ceil(total / pageSize);
|
||
|
||
function drawPage(page) {
|
||
const endIdx = Math.min(page * pageSize, total);
|
||
const visible = subfolders.slice(0, endIdx);
|
||
|
||
const badgeText = formatSourceBadgeText(getSourceMetaById(sourceId));
|
||
const badgeHtml = badgeText
|
||
? `<div class="folder-strip-badge fr-source-badge" title="Source: ${escapeHTML(badgeText)}">${escapeHTML(badgeText)}</div>`
|
||
: '';
|
||
|
||
let html = badgeHtml + visible.map(sf => `
|
||
<div class="folder-item"
|
||
data-folder="${sf.full}"
|
||
draggable="true">
|
||
<span class="folder-svg"></span>
|
||
<div class="folder-name">
|
||
${escapeHTML(sf.name)}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
|
||
if (endIdx < total) {
|
||
html += `
|
||
<button type="button"
|
||
class="folder-strip-load-more">
|
||
${t('load_more_folders') || t('load_more') || 'Load more folders'}
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
strip.innerHTML = html;
|
||
|
||
applyFolderStripLayout(strip);
|
||
wireFolderStripItems(strip, sourceId);
|
||
|
||
const loadMoreBtn = strip.querySelector(".folder-strip-load-more");
|
||
if (loadMoreBtn) {
|
||
loadMoreBtn.addEventListener("click", e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
drawPage(page + 1);
|
||
});
|
||
}
|
||
}
|
||
|
||
drawPage(1);
|
||
}
|
||
|
||
function _trimLabel(str, max = 40) {
|
||
if (!str) return "";
|
||
const s = String(str);
|
||
if (s.length <= max) return s;
|
||
return s.slice(0, max - 1) + "…";
|
||
}
|
||
|
||
// helper to repaint one strip item quickly
|
||
function repaintStripIcon(folder) {
|
||
const el = document.querySelector(`#folderStripContainer .folder-item[data-folder="${CSS.escape(folder)}"]`);
|
||
if (!el) return;
|
||
const iconSpan = el.querySelector('.folder-svg');
|
||
if (!iconSpan) return;
|
||
|
||
const hex = (window.folderColorMap && window.folderColorMap[folder]) || '#f6b84e';
|
||
const front = hex;
|
||
const back = _lighten(hex, 14);
|
||
const stroke = _darken(hex, 22);
|
||
el.style.setProperty('--filr-folder-front', front);
|
||
el.style.setProperty('--filr-folder-back', back);
|
||
el.style.setProperty('--filr-folder-stroke', stroke);
|
||
|
||
const kind = iconSpan.dataset.kind || 'empty';
|
||
iconSpan.innerHTML = folderSVG(kind, { encrypted: isEncryptedForFolderIcon(folder) });
|
||
}
|
||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||
const DEFAULT_HOVER_PREVIEW_MAX_MB = 8;
|
||
const MIN_HOVER_PREVIEW_MAX_MB = 1;
|
||
const MAX_HOVER_PREVIEW_MAX_MB = 50;
|
||
const DEFAULT_HOVER_PREVIEW_MAX_VIDEO_MB = 200;
|
||
const MIN_HOVER_PREVIEW_MAX_VIDEO_MB = 1;
|
||
const MAX_HOVER_PREVIEW_MAX_VIDEO_MB = 2048;
|
||
const DEFAULT_FILE_LIST_SUMMARY_DEPTH = 2;
|
||
const MIN_FILE_LIST_SUMMARY_DEPTH = 0;
|
||
const MAX_FILE_LIST_SUMMARY_DEPTH = 10;
|
||
const OFFICE_SNIPPET_EXTS = new Set([
|
||
'doc', 'docx', 'docm', 'dotx',
|
||
'xls', 'xlsx', 'xlsm', 'xltx',
|
||
'ppt', 'pptx', 'pptm', 'potx'
|
||
]);
|
||
const _fileSnippetCache = new Map();
|
||
|
||
function getMaxImagePreviewBytes() {
|
||
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
|
||
const display = (cfg && typeof cfg.display === 'object') ? cfg.display : {};
|
||
const raw = parseInt(display.hoverPreviewMaxImageMb, 10);
|
||
const mb = Number.isFinite(raw) ? raw : DEFAULT_HOVER_PREVIEW_MAX_MB;
|
||
const clamped = Math.max(MIN_HOVER_PREVIEW_MAX_MB, Math.min(MAX_HOVER_PREVIEW_MAX_MB, mb));
|
||
return clamped * 1024 * 1024;
|
||
}
|
||
|
||
function getMaxVideoPreviewBytes() {
|
||
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
|
||
const display = (cfg && typeof cfg.display === 'object') ? cfg.display : {};
|
||
const raw = parseInt(display.hoverPreviewMaxVideoMb, 10);
|
||
const mb = Number.isFinite(raw) ? raw : DEFAULT_HOVER_PREVIEW_MAX_VIDEO_MB;
|
||
const clamped = Math.max(MIN_HOVER_PREVIEW_MAX_VIDEO_MB, Math.min(MAX_HOVER_PREVIEW_MAX_VIDEO_MB, mb));
|
||
return clamped * 1024 * 1024;
|
||
}
|
||
|
||
function getFileListSummaryDepth() {
|
||
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
|
||
const display = (cfg && typeof cfg.display === 'object') ? cfg.display : {};
|
||
const raw = parseInt(display.fileListSummaryDepth, 10);
|
||
const depth = Number.isFinite(raw) ? raw : DEFAULT_FILE_LIST_SUMMARY_DEPTH;
|
||
return Math.max(MIN_FILE_LIST_SUMMARY_DEPTH, Math.min(MAX_FILE_LIST_SUMMARY_DEPTH, depth));
|
||
}
|
||
|
||
function getFileExt(name) {
|
||
const dot = name.lastIndexOf(".");
|
||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
||
}
|
||
|
||
async function fillFileSnippet(file, snippetEl) {
|
||
if (!snippetEl) return;
|
||
snippetEl.textContent = "";
|
||
snippetEl.style.display = "none";
|
||
|
||
const folder = file.folder || window.currentFolder || "root";
|
||
const key = `${folder}::${file.name}`;
|
||
const ext = getFileExt(file.name || "");
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
const isOffice = OFFICE_SNIPPET_EXTS.has(ext);
|
||
|
||
// Reuse cache if we have it
|
||
if (_fileSnippetCache.has(key)) {
|
||
const cached = _fileSnippetCache.get(key);
|
||
if (cached) {
|
||
snippetEl.textContent = cached;
|
||
snippetEl.style.display = "block";
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ============================
|
||
// OFFICE DOCS (DOCX/XLSX/PPTX)
|
||
// ============================
|
||
if (isOffice) {
|
||
// Size guard (avoid parsing massive Office files)
|
||
const MAX_OFFICE_BYTES = 20 * 1024 * 1024; // 20 MiB
|
||
if (bytes != null && bytes > MAX_OFFICE_BYTES) {
|
||
const msg = t("no_preview_available") || "No preview available";
|
||
snippetEl.style.display = "block";
|
||
snippetEl.textContent = msg;
|
||
_fileSnippetCache.set(key, msg);
|
||
return;
|
||
}
|
||
|
||
snippetEl.style.display = "block";
|
||
snippetEl.textContent = t("loading") || "Loading...";
|
||
|
||
try {
|
||
const url = withBase(`/api/file/snippet.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file.name)}&t=${Date.now()}`);
|
||
const res = await fetch(url, { credentials: "include" });
|
||
if (!res.ok) throw 0;
|
||
|
||
const j = await res.json().catch(() => ({}));
|
||
let text = (j && typeof j.snippet === "string") ? j.snippet : "";
|
||
text = text || "";
|
||
|
||
if (!text) {
|
||
snippetEl.textContent = "";
|
||
snippetEl.style.display = "none";
|
||
_fileSnippetCache.set(key, "");
|
||
return;
|
||
}
|
||
|
||
// Same visual rule as before: 6 lines, 600 chars, but let lines be a bit wider
|
||
const MAX_LINES = 6;
|
||
const MAX_CHARS_TOTAL = 600;
|
||
const MAX_LINE_CHARS = 60;
|
||
|
||
const rawLines = text.split(/\r?\n/);
|
||
|
||
let visibleLines = rawLines.slice(0, MAX_LINES).map(line =>
|
||
_trimLabel(line, MAX_LINE_CHARS)
|
||
);
|
||
|
||
let truncated =
|
||
rawLines.length > MAX_LINES ||
|
||
visibleLines.some((line, idx) => {
|
||
const orig = rawLines[idx] || "";
|
||
return orig.length > MAX_LINE_CHARS;
|
||
});
|
||
|
||
let snippet = visibleLines.join("\n");
|
||
|
||
if (snippet.length > MAX_CHARS_TOTAL) {
|
||
snippet = snippet.slice(0, MAX_CHARS_TOTAL);
|
||
truncated = true;
|
||
}
|
||
|
||
snippet = snippet.trim();
|
||
let finalSnippet = snippet || "(empty file)";
|
||
if (truncated || j.truncated === true) {
|
||
finalSnippet += "\n…";
|
||
}
|
||
|
||
_fileSnippetCache.set(key, finalSnippet);
|
||
snippetEl.textContent = finalSnippet;
|
||
|
||
} catch (e) {
|
||
snippetEl.textContent = "";
|
||
snippetEl.style.display = "none";
|
||
_fileSnippetCache.set(key, "");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ============================
|
||
// EXISTING TEXT FILE BEHAVIOR
|
||
// ============================
|
||
if (!canEditFile(file.name)) {
|
||
// No text preview possible for this type – cache the fact and bail
|
||
_fileSnippetCache.set(key, "");
|
||
return;
|
||
}
|
||
|
||
if (bytes != null && bytes > TEXT_PREVIEW_MAX_BYTES) {
|
||
// File is too large to safely preview inline
|
||
const msg = t("no_preview_available") || "No preview available";
|
||
snippetEl.style.display = "block";
|
||
snippetEl.textContent = msg;
|
||
_fileSnippetCache.set(key, msg);
|
||
return;
|
||
}
|
||
|
||
snippetEl.style.display = "block";
|
||
snippetEl.textContent = t("loading") || "Loading...";
|
||
|
||
try {
|
||
const sourceId = String(file.sourceId || getActivePaneSourceId() || '').trim();
|
||
const url = apiFileUrl(folder, file.name, true, sourceId);
|
||
const res = await fetch(url, { credentials: "include" });
|
||
if (!res.ok) throw 0;
|
||
const text = await res.text();
|
||
|
||
const MAX_LINES = 6;
|
||
const MAX_CHARS_TOTAL = 600;
|
||
const MAX_LINE_CHARS = 20;
|
||
|
||
const allLines = text.split(/\r?\n/);
|
||
|
||
let visibleLines = allLines.slice(0, MAX_LINES).map(line =>
|
||
_trimLabel(line, MAX_LINE_CHARS)
|
||
);
|
||
|
||
let truncated =
|
||
allLines.length > MAX_LINES ||
|
||
visibleLines.some((line, idx) => {
|
||
const orig = allLines[idx] || "";
|
||
return orig.length > MAX_LINE_CHARS;
|
||
});
|
||
|
||
let snippet = visibleLines.join("\n");
|
||
|
||
if (snippet.length > MAX_CHARS_TOTAL) {
|
||
snippet = snippet.slice(0, MAX_CHARS_TOTAL);
|
||
truncated = true;
|
||
}
|
||
|
||
snippet = snippet.trim();
|
||
let finalSnippet = snippet || "(empty file)";
|
||
if (truncated) {
|
||
finalSnippet += "\n…";
|
||
}
|
||
|
||
_fileSnippetCache.set(key, finalSnippet);
|
||
snippetEl.textContent = finalSnippet;
|
||
|
||
} catch (e) {
|
||
snippetEl.textContent = "";
|
||
snippetEl.style.display = "none";
|
||
_fileSnippetCache.set(key, "");
|
||
}
|
||
}
|
||
|
||
function wireEllipsisContextMenu(fileListContent) {
|
||
if (!fileListContent) return;
|
||
|
||
fileListContent
|
||
.querySelectorAll(".btn-actions-ellipsis")
|
||
.forEach(btn => {
|
||
btn.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const row = btn.closest("tr");
|
||
if (!row) return;
|
||
|
||
const rect = btn.getBoundingClientRect();
|
||
const evt = new MouseEvent("contextmenu", {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: rect.left + rect.width / 2,
|
||
clientY: rect.bottom
|
||
});
|
||
|
||
row.dispatchEvent(evt);
|
||
});
|
||
});
|
||
}
|
||
|
||
let inlineRenameState = null;
|
||
|
||
function clearInlineRenameState({ restore = true } = {}) {
|
||
const state = inlineRenameState;
|
||
if (!state) return;
|
||
|
||
inlineRenameState = null;
|
||
|
||
try {
|
||
if (state.input && state.input.parentNode) {
|
||
state.input.parentNode.removeChild(state.input);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
if (restore) {
|
||
if (state.type === 'file' && state.nameCell) {
|
||
state.nameCell.innerHTML = state.originalHtml;
|
||
} else if (state.type === 'folder' && state.nameSpan) {
|
||
state.nameSpan.style.display = '';
|
||
state.nameSpan.textContent = state.originalName;
|
||
}
|
||
}
|
||
|
||
if (state.row) {
|
||
state.row.classList.remove('inline-rename-active');
|
||
if (state.prevDraggable == null) {
|
||
state.row.removeAttribute('draggable');
|
||
} else {
|
||
state.row.setAttribute('draggable', state.prevDraggable);
|
||
}
|
||
}
|
||
}
|
||
|
||
function focusRenameInput(input, name, selectBase) {
|
||
try {
|
||
input.focus();
|
||
if (selectBase) {
|
||
const lastDot = String(name || '').lastIndexOf('.');
|
||
if (lastDot > 0) {
|
||
input.setSelectionRange(0, lastDot);
|
||
} else {
|
||
input.select();
|
||
}
|
||
} else {
|
||
input.select();
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function startInlineRenameForFileRow(row, file, folder) {
|
||
if (!row || !file) return false;
|
||
if (window.viewMode !== 'table') return false;
|
||
|
||
const nameCell = row.querySelector('.file-name-cell');
|
||
if (!nameCell) return false;
|
||
|
||
clearInlineRenameState();
|
||
hideHoverPreview();
|
||
|
||
const oldName = file.name || '';
|
||
const originalHtml = nameCell.innerHTML;
|
||
|
||
nameCell.textContent = '';
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = oldName;
|
||
input.className = 'inline-rename-input form-control';
|
||
input.setAttribute('aria-label', t('rename') || 'Rename');
|
||
input.style.width = '100%';
|
||
input.style.maxWidth = '100%';
|
||
input.style.fontSize = 'inherit';
|
||
input.style.padding = '2px 6px';
|
||
input.style.height = 'auto';
|
||
input.style.boxSizing = 'border-box';
|
||
|
||
nameCell.appendChild(input);
|
||
|
||
const state = {
|
||
type: 'file',
|
||
row,
|
||
input,
|
||
nameCell,
|
||
originalHtml,
|
||
oldName,
|
||
folder: folder || window.currentFolder || 'root',
|
||
submitting: false,
|
||
prevDraggable: row.getAttribute('draggable')
|
||
};
|
||
inlineRenameState = state;
|
||
row.setAttribute('draggable', 'false');
|
||
row.classList.add('inline-rename-active');
|
||
|
||
const commit = async () => {
|
||
if (!inlineRenameState || inlineRenameState !== state || state.submitting) return;
|
||
const newName = String(input.value || '').trim();
|
||
if (!newName || newName === oldName) {
|
||
clearInlineRenameState();
|
||
return;
|
||
}
|
||
|
||
state.submitting = true;
|
||
input.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch(withBase('/api/file/renameFile.php'), {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': window.csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
folder: state.folder,
|
||
oldName: oldName,
|
||
newName: newName
|
||
})
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (data && data.success) {
|
||
showToast(t('rename_file_success'), 'success');
|
||
clearInlineRenameState({ restore: false });
|
||
loadFileList(state.folder);
|
||
return;
|
||
}
|
||
const errMsg = data.error || t('unknown_error');
|
||
showToast(t('rename_file_error', { error: errMsg }), 'error');
|
||
} catch (err) {
|
||
console.error('Error renaming file:', err);
|
||
showToast(t('rename_file_error_generic'), 'error');
|
||
}
|
||
|
||
if (inlineRenameState === state) {
|
||
state.submitting = false;
|
||
input.disabled = false;
|
||
focusRenameInput(input, newName, true);
|
||
}
|
||
};
|
||
|
||
const cancel = () => {
|
||
clearInlineRenameState();
|
||
};
|
||
|
||
const stop = (e) => { e.stopPropagation(); };
|
||
input.addEventListener('mousedown', stop);
|
||
input.addEventListener('click', stop);
|
||
input.addEventListener('dragstart', stop);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
commit();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
cancel();
|
||
}
|
||
});
|
||
input.addEventListener('blur', () => {
|
||
if (inlineRenameState === state && !state.submitting) {
|
||
commit();
|
||
}
|
||
});
|
||
|
||
requestAnimationFrame(() => {
|
||
focusRenameInput(input, oldName, true);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
function startInlineRenameForFolderRow(row, folderPath) {
|
||
if (!row || !folderPath) return false;
|
||
if (window.viewMode !== 'table') return false;
|
||
|
||
const wrap = row.querySelector('.folder-row-inner');
|
||
const nameSpan = row.querySelector('.folder-row-name');
|
||
if (!wrap || !nameSpan) return false;
|
||
|
||
clearInlineRenameState();
|
||
hideHoverPreview();
|
||
|
||
const oldName = nameSpan.textContent || (String(folderPath).split('/').pop() || '');
|
||
const originalName = oldName;
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = oldName;
|
||
input.className = 'inline-rename-input form-control';
|
||
input.setAttribute('aria-label', t('rename_folder') || 'Rename folder');
|
||
input.style.width = '100%';
|
||
input.style.maxWidth = '100%';
|
||
input.style.fontSize = 'inherit';
|
||
input.style.padding = '2px 6px';
|
||
input.style.height = 'auto';
|
||
input.style.boxSizing = 'border-box';
|
||
|
||
const metaSpan = wrap.querySelector('.folder-row-meta');
|
||
nameSpan.style.display = 'none';
|
||
if (metaSpan) {
|
||
wrap.insertBefore(input, metaSpan);
|
||
} else {
|
||
wrap.appendChild(input);
|
||
}
|
||
|
||
const state = {
|
||
type: 'folder',
|
||
row,
|
||
input,
|
||
nameSpan,
|
||
originalName,
|
||
folderPath,
|
||
submitting: false,
|
||
prevDraggable: row.getAttribute('draggable')
|
||
};
|
||
inlineRenameState = state;
|
||
row.setAttribute('draggable', 'false');
|
||
row.classList.add('inline-rename-active');
|
||
|
||
const commit = async () => {
|
||
if (!inlineRenameState || inlineRenameState !== state || state.submitting) return;
|
||
const newName = String(input.value || '').trim();
|
||
if (!newName || newName === oldName) {
|
||
clearInlineRenameState();
|
||
return;
|
||
}
|
||
|
||
state.submitting = true;
|
||
input.disabled = true;
|
||
|
||
try {
|
||
const result = await renameFolderInline(folderPath, newName, { selectAfter: false });
|
||
if (result && result.success) {
|
||
clearInlineRenameState({ restore: false });
|
||
const reloadFolder = window.currentFolder || getParentFolder(folderPath);
|
||
loadFileList(reloadFolder);
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.error('Error renaming folder:', err);
|
||
}
|
||
|
||
if (inlineRenameState === state) {
|
||
state.submitting = false;
|
||
input.disabled = false;
|
||
focusRenameInput(input, newName, false);
|
||
}
|
||
};
|
||
|
||
const cancel = () => {
|
||
clearInlineRenameState();
|
||
};
|
||
|
||
const stop = (e) => { e.stopPropagation(); };
|
||
input.addEventListener('mousedown', stop);
|
||
input.addEventListener('click', stop);
|
||
input.addEventListener('dragstart', stop);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
commit();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
cancel();
|
||
}
|
||
});
|
||
input.addEventListener('blur', () => {
|
||
if (inlineRenameState === state && !state.submitting) {
|
||
commit();
|
||
}
|
||
});
|
||
|
||
requestAnimationFrame(() => {
|
||
focusRenameInput(input, oldName, false);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
export function startInlineRenameFromContext(file, row) {
|
||
if (!file || !row) return false;
|
||
if (row.classList.contains('folder-row')) return false;
|
||
return startInlineRenameForFileRow(row, file, file.folder || window.currentFolder || 'root');
|
||
}
|
||
|
||
let hoverPreviewEl = null;
|
||
let hoverPreviewTimer = null;
|
||
let hoverPreviewActiveRow = null;
|
||
let hoverPreviewContext = null;
|
||
let hoverPreviewHoveringCard = false;
|
||
|
||
// Let other modules (drag/drop) kill the hover card instantly.
|
||
export function cancelHoverPreview() {
|
||
try {
|
||
if (hoverPreviewTimer) {
|
||
clearTimeout(hoverPreviewTimer);
|
||
hoverPreviewTimer = null;
|
||
}
|
||
} catch (e) {}
|
||
|
||
hoverPreviewActiveRow = null;
|
||
hoverPreviewContext = null;
|
||
hoverPreviewHoveringCard = false;
|
||
|
||
if (hoverPreviewEl) {
|
||
hoverPreviewEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function isHoverPreviewDisabled() {
|
||
if (window.disableHoverPreview === true) return true;
|
||
|
||
// Disable on touch / coarse pointer devices
|
||
try {
|
||
const coarse = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
|
||
if (coarse) return true;
|
||
} catch (e) {}
|
||
|
||
try {
|
||
return localStorage.getItem("disableHoverPreview") === "true";
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function ensureHoverPreviewEl() {
|
||
if (hoverPreviewEl) return hoverPreviewEl;
|
||
|
||
const el = document.createElement("div");
|
||
el.id = "hoverPreview";
|
||
el.style.position = "fixed";
|
||
el.style.zIndex = "9999";
|
||
el.style.display = "none";
|
||
el.innerHTML = `
|
||
<div class="hover-preview-card">
|
||
<div class="hover-preview-grid">
|
||
<div class="hover-preview-left">
|
||
<div class="hover-preview-thumb"></div>
|
||
<pre class="hover-preview-snippet"></pre>
|
||
</div>
|
||
<div class="hover-preview-right">
|
||
<div class="hover-preview-title"></div>
|
||
<div class="hover-preview-meta"></div>
|
||
<div class="hover-preview-props"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(el);
|
||
hoverPreviewEl = el;
|
||
|
||
// ---- Layout + sizing tweaks ---------------------------------
|
||
const card = el.querySelector(".hover-preview-card");
|
||
const grid = el.querySelector(".hover-preview-grid");
|
||
const leftCol = el.querySelector(".hover-preview-left");
|
||
const rightCol = el.querySelector(".hover-preview-right");
|
||
const thumb = el.querySelector(".hover-preview-thumb");
|
||
const snippet = el.querySelector(".hover-preview-snippet");
|
||
const titleEl = el.querySelector(".hover-preview-title");
|
||
const metaEl = el.querySelector(".hover-preview-meta");
|
||
const propsEl = el.querySelector(".hover-preview-props");
|
||
|
||
if (card) {
|
||
card.style.minWidth = "380px"; // was 420
|
||
card.style.maxWidth = "600px"; // was 640
|
||
card.style.minHeight = "200px"; // was 220
|
||
card.style.padding = "8px 10px"; // slightly tighter padding
|
||
card.style.overflow = "hidden";
|
||
}
|
||
|
||
if (grid) {
|
||
grid.style.display = "grid";
|
||
grid.style.gridTemplateColumns = "200px minmax(240px, 1fr)"; // both columns ~9% smaller
|
||
grid.style.gap = "10px";
|
||
grid.style.alignItems = "center";
|
||
}
|
||
|
||
if (leftCol) {
|
||
leftCol.style.display = "flex";
|
||
leftCol.style.flexDirection = "column";
|
||
leftCol.style.justifyContent = "center";
|
||
leftCol.style.minWidth = "0";
|
||
}
|
||
|
||
if (rightCol) {
|
||
rightCol.style.display = "flex";
|
||
rightCol.style.flexDirection = "column";
|
||
rightCol.style.justifyContent = "center";
|
||
rightCol.style.minWidth = "0";
|
||
rightCol.style.overflow = "hidden";
|
||
}
|
||
|
||
if (thumb) {
|
||
thumb.style.display = "flex";
|
||
thumb.style.alignItems = "center";
|
||
thumb.style.justifyContent = "center";
|
||
thumb.style.minHeight = "120px"; // was 140
|
||
thumb.style.marginBottom = "4px"; // slightly tighter
|
||
}
|
||
|
||
|
||
if (snippet) {
|
||
snippet.style.marginTop = "4px";
|
||
snippet.style.maxHeight = "120px";
|
||
snippet.style.overflow = "auto";
|
||
snippet.style.fontSize = "0.78rem";
|
||
snippet.style.whiteSpace = "pre-wrap";
|
||
snippet.style.padding = "6px 8px";
|
||
snippet.style.borderRadius = "6px";
|
||
// Dark-mode friendly styling that still looks OK in light mode
|
||
//snippet.style.backgroundColor = "rgba(39, 39, 39, 0.92)";
|
||
snippet.style.color = "#e5e7eb";
|
||
}
|
||
|
||
if (titleEl) {
|
||
titleEl.style.fontWeight = "600";
|
||
titleEl.style.fontSize = "0.95rem";
|
||
titleEl.style.marginBottom = "2px";
|
||
titleEl.style.whiteSpace = "nowrap";
|
||
titleEl.style.overflow = "hidden";
|
||
titleEl.style.textOverflow = "ellipsis";
|
||
titleEl.style.maxWidth = "100%";
|
||
}
|
||
|
||
if (metaEl) {
|
||
metaEl.style.fontSize = "0.8rem";
|
||
metaEl.style.opacity = "0.8";
|
||
metaEl.style.marginBottom = "6px";
|
||
metaEl.style.whiteSpace = "nowrap";
|
||
metaEl.style.overflow = "hidden";
|
||
metaEl.style.textOverflow = "ellipsis";
|
||
metaEl.style.maxWidth = "100%";
|
||
}
|
||
|
||
if (propsEl) {
|
||
propsEl.style.fontSize = "0.76rem";
|
||
propsEl.style.lineHeight = "1.3";
|
||
propsEl.style.maxHeight = "140px";
|
||
propsEl.style.overflow = "auto";
|
||
propsEl.style.paddingRight = "4px";
|
||
propsEl.style.wordBreak = "break-word";
|
||
}
|
||
|
||
// Allow the user to move onto the card without it vanishing
|
||
el.addEventListener("mouseenter", () => {
|
||
hoverPreviewHoveringCard = true;
|
||
});
|
||
|
||
el.addEventListener("mouseleave", () => {
|
||
hoverPreviewHoveringCard = false;
|
||
// If we've left both the row and the card, hide after a tiny delay
|
||
setTimeout(() => {
|
||
if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
|
||
hideHoverPreview();
|
||
}
|
||
}, 120);
|
||
});
|
||
|
||
// Click anywhere on the card = open preview/editor/folder
|
||
el.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
if (!hoverPreviewContext) return;
|
||
|
||
const ctx = hoverPreviewContext;
|
||
|
||
// Hide the hover card immediately so it doesn't hang around
|
||
hideHoverPreview();
|
||
|
||
if (ctx.type === "file") {
|
||
openDefaultFileFromHover(ctx.file);
|
||
} else if (ctx.type === "folder") {
|
||
const dest = ctx.folder;
|
||
if (dest) {
|
||
setCurrentFolderContext(dest, { resetPage: true });
|
||
loadFileList(dest);
|
||
}
|
||
}
|
||
});
|
||
|
||
return el;
|
||
}
|
||
|
||
function hideHoverPreview() {
|
||
cancelHoverPreview();
|
||
}
|
||
|
||
// allow ESC to quickly dismiss the hover preview
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" || e.key === "Esc") {
|
||
hideHoverPreview();
|
||
}
|
||
});
|
||
|
||
function parentFolderOf(path) {
|
||
if (!path || path === 'root') return 'root';
|
||
const parts = String(path).split('/').filter(Boolean);
|
||
if (parts.length <= 1) return 'root';
|
||
parts.pop();
|
||
return parts.join('/');
|
||
}
|
||
|
||
function normalizeFolderToastKey(path) {
|
||
const raw = String(path || '').trim();
|
||
if (!raw || raw.toLowerCase() === 'root') return 'root';
|
||
return raw;
|
||
}
|
||
|
||
function formatFolderPathForToast(path) {
|
||
const key = normalizeFolderToastKey(path);
|
||
return key === 'root' ? (t('root_folder') || 'Root') : key;
|
||
}
|
||
|
||
function movedFolderNameForToast(path) {
|
||
const key = normalizeFolderToastKey(path);
|
||
if (key === 'root') return t('root_folder') || 'Root';
|
||
const parts = key.split('/').filter(Boolean);
|
||
return parts.length ? parts[parts.length - 1] : key;
|
||
}
|
||
|
||
function buildMoveFolderSuccessToast(sourceFolder, destinationFolder) {
|
||
const movedName = movedFolderNameForToast(sourceFolder);
|
||
const sourceParent = normalizeFolderToastKey(parentFolderOf(sourceFolder));
|
||
const destFolder = normalizeFolderToastKey(destinationFolder);
|
||
const destLabel = formatFolderPathForToast(destFolder);
|
||
if (sourceParent !== destFolder) {
|
||
const sourceLabel = formatFolderPathForToast(sourceParent);
|
||
return t('move_folder_success_named_from_to', { name: movedName, source: sourceLabel, folder: destLabel });
|
||
}
|
||
return t('move_folder_success_named_to', { name: movedName, folder: destLabel });
|
||
}
|
||
|
||
function invalidateFolderStats(folders, sourceId = '') {
|
||
try {
|
||
const arr = Array.isArray(folders) ? folders : [folders];
|
||
window.dispatchEvent(new CustomEvent('folderStatsInvalidated', {
|
||
detail: { folders: arr, sourceId }
|
||
}));
|
||
} catch (e) {
|
||
// best effort only
|
||
}
|
||
}
|
||
|
||
function applyFolderStripLayout(strip) {
|
||
if (!strip) return;
|
||
const hasItems = strip.querySelector('.folder-item') !== null;
|
||
if (!hasItems) {
|
||
strip.style.display = 'none';
|
||
strip.classList.remove('folder-strip-mobile', 'folder-strip-desktop');
|
||
return;
|
||
}
|
||
|
||
const isMobile = window.innerWidth <= 640; // tweak breakpoint if you want
|
||
|
||
strip.classList.add('folder-strip-container');
|
||
strip.classList.toggle('folder-strip-mobile', isMobile);
|
||
strip.classList.toggle('folder-strip-desktop', !isMobile);
|
||
|
||
strip.style.display = isMobile ? 'block' : 'flex';
|
||
strip.style.overflowX = isMobile ? 'visible' : 'auto';
|
||
strip.style.overflowY = isMobile ? 'auto' : 'hidden';
|
||
}
|
||
|
||
window.addEventListener('resize', () => {
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) applyFolderStripLayout(strip);
|
||
});
|
||
|
||
// Clamp gallery columns when viewport shrinks
|
||
window.addEventListener('resize', () => {
|
||
const maxCols = getGalleryMaxColumns();
|
||
if ((window.galleryColumns || 0) > maxCols) {
|
||
applyGalleryColumns(maxCols);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('keydown', async (e) => {
|
||
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
|
||
if (isTextEntryTarget(e.target)) return;
|
||
|
||
const key = e.key;
|
||
|
||
if (key === 'F2' && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
if (inlineRenameState && inlineRenameState.input) {
|
||
try {
|
||
inlineRenameState.input.focus();
|
||
inlineRenameState.input.select();
|
||
} catch (e2) { /* ignore */ }
|
||
return;
|
||
}
|
||
|
||
const folderCb = document.querySelector('#fileList .folder-checkbox:checked');
|
||
if (folderCb) {
|
||
const folder = folderCb.dataset.folder || '';
|
||
const row = folderCb.closest('tr');
|
||
if (folder && row) {
|
||
startInlineRenameForFolderRow(row, folder);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const fileCbs = document.querySelectorAll('#fileList .file-checkbox:checked');
|
||
if (fileCbs.length) {
|
||
handleRenameSelected(new Event('click'));
|
||
return;
|
||
}
|
||
|
||
const treeFolder = getSelectedTreeFolderPath() || window.currentFolder || 'root';
|
||
try {
|
||
await startInlineRenameInTree(treeFolder);
|
||
} catch (e2) { /* ignore */ }
|
||
return;
|
||
}
|
||
|
||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||
|
||
if (!['F3', 'F4', 'F5', 'F6', 'F7', 'F8'].includes(key)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
if (key === 'F5') return handleCopySelected(new Event('click'));
|
||
if (key === 'F6') return handleMoveSelected(new Event('click'));
|
||
if (key === 'F8') return handleDeleteSelected(new Event('click'));
|
||
|
||
if (key === 'F7') {
|
||
const btn = document.getElementById('createFolderBtn');
|
||
if (btn && !btn.disabled) btn.click();
|
||
return;
|
||
}
|
||
|
||
const files = getActiveSelectedFileObjects();
|
||
if (!files.length) {
|
||
showToast(t('no_files_selected') || 'No files selected.', 'warning');
|
||
return;
|
||
}
|
||
const file = files[0];
|
||
const folder = file.folder || window.currentFolder || 'root';
|
||
const sourceId = String(file.sourceId || '').trim() || getActivePaneSourceId();
|
||
|
||
if (key === 'F3') {
|
||
try {
|
||
const url = apiFileUrl(folder, file.name, true, sourceId);
|
||
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||
m.previewFile(url, file.name);
|
||
} catch (err) {
|
||
console.error('Failed to open preview', err);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (key === 'F4') {
|
||
if (!canEditFile(file.name) || !file.editable) {
|
||
showToast(t('file_not_editable') || 'File is not editable.', 'warning');
|
||
return;
|
||
}
|
||
try {
|
||
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||
m.editFile(file.name, folder, sourceId, file.sizeBytes);
|
||
} catch (err) {
|
||
console.error('Failed to open editor', err);
|
||
}
|
||
}
|
||
});
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
|
||
if (isTextEntryTarget(e.target)) return;
|
||
|
||
const isShortcut = (e.key === 'n' || e.key === 'N') && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey;
|
||
if (!isShortcut) return;
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const folderCb = document.querySelector('#fileList .folder-checkbox:checked');
|
||
const targetFolder =
|
||
(folderCb && folderCb.dataset.folder) ||
|
||
getSelectedTreeFolderPath() ||
|
||
window.currentFolder ||
|
||
'root';
|
||
|
||
if (targetFolder) {
|
||
window.currentFolder = targetFolder;
|
||
}
|
||
|
||
const btn = document.getElementById('createFolderBtn');
|
||
if (btn && !btn.disabled) {
|
||
btn.click();
|
||
return;
|
||
}
|
||
|
||
const modal = document.getElementById('createFolderModal');
|
||
if (modal) {
|
||
modal.style.display = 'block';
|
||
const input = document.getElementById('newFolderName');
|
||
if (input) input.focus();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (window.__frIsModalOpen && window.__frIsModalOpen()) return;
|
||
if (isTextEntryTarget(e.target)) return;
|
||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||
|
||
const key = e.key;
|
||
|
||
if (key === '/' && !e.shiftKey) {
|
||
const input = document.getElementById('searchInput');
|
||
if (input) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (key === '?' || (key === '/' && e.shiftKey)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
showToast(t('keyboard_shortcuts_help'), 6000, 'info');
|
||
return;
|
||
}
|
||
|
||
if (key === 'Delete') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const selectedFolder = getSelectedFolderPath();
|
||
if (selectedFolder) {
|
||
setCurrentFolderContext(selectedFolder);
|
||
openDeleteFolderModal();
|
||
return;
|
||
}
|
||
handleDeleteSelected(new Event('click'));
|
||
}
|
||
});
|
||
|
||
// Listen once: update strip + tree + inline rows when folder color changes
|
||
window.addEventListener('folderColorChanged', (e) => {
|
||
const { folder } = e.detail || {};
|
||
if (!folder) return;
|
||
|
||
// 1) Update the strip (if that folder is currently shown)
|
||
repaintStripIcon(folder);
|
||
|
||
// 2) Refresh the tree icon (existing function)
|
||
try { refreshFolderIcon(folder); } catch (e) { }
|
||
|
||
// 3) Repaint any inline folder rows in the file table
|
||
try {
|
||
const safeFolder = CSS.escape(folder);
|
||
const activeSourceId = getActivePaneSourceId();
|
||
document
|
||
.querySelectorAll(`#fileList tr.folder-row[data-folder="${safeFolder}"]`)
|
||
.forEach(row => {
|
||
// reuse the same helper we used when injecting inline rows
|
||
attachStripIconAsync(row, folder, 28, { sourceId: activeSourceId });
|
||
});
|
||
} catch (e) {
|
||
// CSS.escape might not exist on very old browsers; fail silently
|
||
}
|
||
});
|
||
|
||
// Hide "Edit" for files >10 MiB
|
||
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||
|
||
// Max number of files allowed for non-ZIP multi-download
|
||
export const MAX_NONZIP_MULTI_DOWNLOAD = 20;
|
||
|
||
// Global queue + panel ref for stepper-style downloads
|
||
window.__nonZipDownloadQueue = window.__nonZipDownloadQueue || [];
|
||
window.__nonZipDownloadPanel = window.__nonZipDownloadPanel || null;
|
||
|
||
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
|
||
let __fileListReqSeq = { primary: 0, secondary: 0 };
|
||
let __dualPaneRestoreRunning = false;
|
||
|
||
window.itemsPerPage = parseInt(
|
||
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '50',
|
||
10
|
||
);
|
||
window.currentPage = window.currentPage || 1;
|
||
window.viewMode = localStorage.getItem("viewMode") || "table";
|
||
window.currentSubfolders = window.currentSubfolders || [];
|
||
window.currentSubfoldersSourceId = window.currentSubfoldersSourceId || '';
|
||
window.currentSubfoldersFolder = window.currentSubfoldersFolder || '';
|
||
window.currentFileListPaging = window.currentFileListPaging || null;
|
||
|
||
const FILE_LIST_CURSOR_PAGE_SIZE_MIN = 10;
|
||
const FILE_LIST_CURSOR_PAGE_SIZE_MAX = 500;
|
||
|
||
const PANE_FOLDER_STORAGE_KEYS = {
|
||
primary: 'frPaneFolderPrimary',
|
||
secondary: 'frPaneFolderSecondary'
|
||
};
|
||
const PANE_SOURCE_STORAGE_KEYS = {
|
||
primary: 'frPaneSourcePrimary',
|
||
secondary: 'frPaneSourceSecondary'
|
||
};
|
||
|
||
function normalizePaneKey(pane) {
|
||
return pane === 'secondary' ? 'secondary' : 'primary';
|
||
}
|
||
|
||
function getGlobalActiveSourceId() {
|
||
try {
|
||
if (typeof window.__frGetActiveSourceId === 'function') {
|
||
return window.__frGetActiveSourceId() || '';
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
const sel = document.getElementById('sourceSelector');
|
||
if (sel && sel.value) return sel.value;
|
||
try {
|
||
const stored = localStorage.getItem('fr_active_source');
|
||
if (stored) return stored;
|
||
} catch (e) { /* ignore */ }
|
||
return '';
|
||
}
|
||
|
||
function getSourceNameById(sourceId) {
|
||
const id = String(sourceId || '').trim();
|
||
if (!id) return '';
|
||
try {
|
||
if (typeof window.__frGetSourceNameById === 'function') {
|
||
return String(window.__frGetSourceNameById(id) || '');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
const sel = document.getElementById('sourceSelector');
|
||
if (sel) {
|
||
const opt = Array.from(sel.options).find(o => o.value === id);
|
||
if (opt) return String(opt.dataset?.sourceName || '');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function getSourceTypeById(sourceId) {
|
||
const id = String(sourceId || '').trim();
|
||
if (!id) return '';
|
||
try {
|
||
if (typeof window.__frGetSourceMetaById === 'function') {
|
||
const meta = window.__frGetSourceMetaById(id);
|
||
if (meta && typeof meta === 'object' && meta.type) return String(meta.type || '');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
try {
|
||
if (typeof window.__frGetSourceTypeById === 'function') {
|
||
return String(window.__frGetSourceTypeById(id) || '');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
const sel = document.getElementById('sourceSelector');
|
||
if (sel) {
|
||
const opt = Array.from(sel.options).find(o => o.value === id);
|
||
if (opt) return String(opt.dataset?.sourceType || '');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function isFtpSource(sourceId = '') {
|
||
const type = String(getSourceTypeById(sourceId || getGlobalActiveSourceId()) || '').toLowerCase();
|
||
return type === 'ftp';
|
||
}
|
||
|
||
function isSlowRemoteSource(sourceId = '') {
|
||
const type = String(getSourceTypeById(sourceId || getGlobalActiveSourceId()) || '').toLowerCase();
|
||
if (!type || type === 'local') return false;
|
||
return ['ftp', 'sftp', 'webdav'].includes(type);
|
||
}
|
||
|
||
function getSourceMetaById(sourceId) {
|
||
return {
|
||
name: getSourceNameById(sourceId),
|
||
type: getSourceTypeById(sourceId)
|
||
};
|
||
}
|
||
|
||
function getRootLabel(sourceId = '') {
|
||
const id = sourceId || getGlobalActiveSourceId();
|
||
const name = getSourceNameById(id);
|
||
return name ? `(${name})` : '(Root)';
|
||
}
|
||
|
||
function getRootCrumbLabel(sourceId = '') {
|
||
const id = sourceId || getGlobalActiveSourceId();
|
||
const name = getSourceNameById(id);
|
||
return name || 'root';
|
||
}
|
||
|
||
function getLastOpenedFolderKey(sourceId = '') {
|
||
const id = String(sourceId || '').trim();
|
||
return id ? `lastOpenedFolder.${id}` : 'lastOpenedFolder';
|
||
}
|
||
|
||
function getLastOpenedFolder(sourceId = '') {
|
||
const key = getLastOpenedFolderKey(sourceId);
|
||
try {
|
||
const val = String(localStorage.getItem(key) || '').trim();
|
||
return val;
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function setLastOpenedFolder(folder, sourceId = '') {
|
||
if (!folder) return;
|
||
const key = getLastOpenedFolderKey(sourceId);
|
||
try {
|
||
localStorage.setItem(key, folder);
|
||
if (key !== 'lastOpenedFolder') {
|
||
localStorage.setItem('lastOpenedFolder', folder);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function formatSourceTypeLabel(type) {
|
||
const t = String(type || '').trim();
|
||
if (!t) return '';
|
||
if (t.toLowerCase() === 'local') return 'Local';
|
||
if (t.length <= 5) return t.toUpperCase();
|
||
return t;
|
||
}
|
||
|
||
function formatSourceBadgeText(meta) {
|
||
const name = String(meta?.name || '').trim();
|
||
const typeLabel = formatSourceTypeLabel(meta?.type || '');
|
||
if (typeLabel && name) {
|
||
if (typeLabel.toLowerCase() === name.toLowerCase()) return name;
|
||
return `${typeLabel}: ${name}`;
|
||
}
|
||
return name || typeLabel;
|
||
}
|
||
|
||
function setSourceBadge(titleEl, sourceId) {
|
||
if (!titleEl) return;
|
||
const meta = getSourceMetaById(sourceId);
|
||
const label = formatSourceBadgeText(meta);
|
||
const existing = titleEl.querySelector('.fr-source-badge');
|
||
if (!label) {
|
||
if (existing) existing.remove();
|
||
return;
|
||
}
|
||
const badge = existing || document.createElement('span');
|
||
if (!existing) {
|
||
badge.className = 'fr-source-badge fr-source-badge--title';
|
||
titleEl.appendChild(badge);
|
||
}
|
||
if (existing) {
|
||
badge.classList.add('fr-source-badge', 'fr-source-badge--title');
|
||
}
|
||
badge.textContent = label;
|
||
badge.title = `Source: ${label}`;
|
||
}
|
||
|
||
function refreshSourceBadges() {
|
||
ensurePaneState();
|
||
const active = normalizePaneKey(window.activePane);
|
||
const other = active === 'secondary' ? 'primary' : 'secondary';
|
||
setSourceBadge(document.getElementById('fileListTitle'), getPaneSourceId(active));
|
||
setSourceBadge(document.getElementById('fileListTitleSecondary'), getPaneSourceId(other));
|
||
}
|
||
|
||
try { window.__frRefreshSourceBadges = refreshSourceBadges; } catch (e) { /* ignore */ }
|
||
|
||
function readStoredPaneFolder(pane, sourceId = '') {
|
||
const key = PANE_FOLDER_STORAGE_KEYS[normalizePaneKey(pane)];
|
||
if (!key) return "";
|
||
const scopedKey = sourceId ? `${key}.${sourceId}` : key;
|
||
try {
|
||
if (sourceId) {
|
||
return String(localStorage.getItem(scopedKey) || '').trim();
|
||
}
|
||
return String(localStorage.getItem(scopedKey) || '').trim();
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function readStoredPaneSource(pane) {
|
||
const key = PANE_SOURCE_STORAGE_KEYS[normalizePaneKey(pane)];
|
||
if (!key) return "";
|
||
try {
|
||
return String(localStorage.getItem(key) || '').trim();
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function persistPaneFolder(pane, folder, sourceId = '') {
|
||
const key = PANE_FOLDER_STORAGE_KEYS[normalizePaneKey(pane)];
|
||
if (!key || !folder) return;
|
||
const scopedKey = sourceId ? `${key}.${sourceId}` : key;
|
||
try {
|
||
localStorage.setItem(scopedKey, folder);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function persistPaneSource(pane, sourceId) {
|
||
const key = PANE_SOURCE_STORAGE_KEYS[normalizePaneKey(pane)];
|
||
if (!key) return;
|
||
try {
|
||
if (!sourceId) {
|
||
localStorage.removeItem(key);
|
||
return;
|
||
}
|
||
localStorage.setItem(key, sourceId);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function getPaneSourceId(pane) {
|
||
try {
|
||
const state = getPaneState(pane);
|
||
if (state && state.sourceId) return state.sourceId;
|
||
} catch (e) { /* ignore */ }
|
||
const stored = readStoredPaneSource(pane);
|
||
if (stored) return stored;
|
||
return getGlobalActiveSourceId();
|
||
}
|
||
|
||
function getActivePaneSourceId() {
|
||
return getPaneSourceId(normalizePaneKey(window.activePane));
|
||
}
|
||
|
||
function getPaneKeyForElement(el) {
|
||
const pane = el && el.closest ? el.closest('.file-list-pane') : null;
|
||
if (pane && pane.classList.contains('secondary-pane')) return 'secondary';
|
||
if (pane && pane.classList.contains('primary-pane')) return 'primary';
|
||
return normalizePaneKey(window.activePane);
|
||
}
|
||
|
||
function getPaneSourceIdForElement(el) {
|
||
return getPaneSourceId(getPaneKeyForElement(el));
|
||
}
|
||
|
||
(function seedLastOpenedFolderFromPaneStorage() {
|
||
try {
|
||
const dualEnabled = localStorage.getItem('dualPaneMode') === 'true';
|
||
if (!dualEnabled) return;
|
||
const active = normalizePaneKey(localStorage.getItem('activePane'));
|
||
const sourceId = readStoredPaneSource(active) || getGlobalActiveSourceId();
|
||
const folder = readStoredPaneFolder(active, sourceId);
|
||
if (folder) setLastOpenedFolder(folder, sourceId);
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
|
||
function ensurePaneState() {
|
||
if (window.__frPaneState) return;
|
||
const storedPrimarySource = readStoredPaneSource('primary');
|
||
const storedSecondarySource = readStoredPaneSource('secondary');
|
||
const activeSource = getGlobalActiveSourceId();
|
||
const primarySourceId = storedPrimarySource || activeSource;
|
||
const secondarySourceId = storedSecondarySource || primarySourceId || activeSource;
|
||
const storedPrimary = readStoredPaneFolder('primary', primarySourceId);
|
||
const storedSecondary = readStoredPaneFolder('secondary', secondarySourceId);
|
||
const initialFolder =
|
||
storedPrimary ||
|
||
getLastOpenedFolder(primarySourceId) ||
|
||
(primarySourceId ? "root" : (window.currentFolder || "root"));
|
||
window.__frPaneState = {
|
||
primary: {
|
||
currentFolder: initialFolder,
|
||
sourceId: primarySourceId,
|
||
currentSubfolders: Array.isArray(window.currentSubfolders) ? window.currentSubfolders : [],
|
||
currentPage: window.currentPage || 1,
|
||
currentSearchTerm: window.currentSearchTerm || "",
|
||
currentFolderCaps: window.currentFolderCaps || null,
|
||
selectedFolderCaps: window.selectedFolderCaps || null,
|
||
fileData: Array.isArray(fileData) ? fileData : [],
|
||
fileListPaging: window.currentFileListPaging || null,
|
||
hasLoaded: false,
|
||
needsReload: false
|
||
},
|
||
secondary: {
|
||
currentFolder: storedSecondary || null,
|
||
sourceId: secondarySourceId,
|
||
currentSubfolders: [],
|
||
currentPage: 1,
|
||
currentSearchTerm: "",
|
||
currentFolderCaps: null,
|
||
selectedFolderCaps: null,
|
||
fileData: [],
|
||
fileListPaging: null,
|
||
hasLoaded: false,
|
||
needsReload: false
|
||
}
|
||
};
|
||
}
|
||
|
||
function getPaneState(pane) {
|
||
ensurePaneState();
|
||
return window.__frPaneState[normalizePaneKey(pane)];
|
||
}
|
||
|
||
function savePaneState(pane, patch) {
|
||
const state = getPaneState(pane);
|
||
Object.assign(state, patch || {});
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'currentFolder')) {
|
||
const sourceId = state.sourceId || getPaneSourceId(pane);
|
||
persistPaneFolder(pane, state.currentFolder, sourceId);
|
||
}
|
||
if (patch && Object.prototype.hasOwnProperty.call(patch, 'sourceId')) {
|
||
persistPaneSource(pane, state.sourceId || '');
|
||
}
|
||
}
|
||
|
||
function syncPaneStateFromGlobals(pane) {
|
||
savePaneState(pane, {
|
||
currentFolder: window.currentFolder || "root",
|
||
sourceId: getGlobalActiveSourceId(),
|
||
currentSubfolders: Array.isArray(window.currentSubfolders) ? window.currentSubfolders : [],
|
||
currentSubfoldersSourceId: String(window.currentSubfoldersSourceId || ''),
|
||
currentSubfoldersFolder: String(window.currentSubfoldersFolder || ''),
|
||
currentPage: window.currentPage || 1,
|
||
currentSearchTerm: window.currentSearchTerm || "",
|
||
currentFolderCaps: window.currentFolderCaps || null,
|
||
selectedFolderCaps: window.selectedFolderCaps || null,
|
||
fileData: Array.isArray(fileData) ? fileData : [],
|
||
fileListPaging: window.currentFileListPaging || null
|
||
});
|
||
}
|
||
|
||
function syncGlobalsFromPaneState(pane) {
|
||
const state = getPaneState(pane);
|
||
window.currentFolder = state.currentFolder || "root";
|
||
window.currentSubfolders = Array.isArray(state.currentSubfolders) ? state.currentSubfolders : [];
|
||
window.currentSubfoldersSourceId = String(state.currentSubfoldersSourceId || '');
|
||
window.currentSubfoldersFolder = String(state.currentSubfoldersFolder || '');
|
||
window.currentPage = state.currentPage || 1;
|
||
window.currentSearchTerm = state.currentSearchTerm || "";
|
||
window.currentFolderCaps = state.currentFolderCaps || null;
|
||
window.selectedFolderCaps = state.selectedFolderCaps || null;
|
||
fileData = Array.isArray(state.fileData) ? state.fileData : [];
|
||
window.currentFileListPaging = state.fileListPaging || null;
|
||
}
|
||
|
||
function getFileListCursorPageSize() {
|
||
const raw = parseInt(window.itemsPerPage || localStorage.getItem('itemsPerPage') || '50', 10);
|
||
const value = Number.isFinite(raw) ? raw : 50;
|
||
return Math.max(FILE_LIST_CURSOR_PAGE_SIZE_MIN, Math.min(FILE_LIST_CURSOR_PAGE_SIZE_MAX, value));
|
||
}
|
||
|
||
function getPaneFileListPaging(pane) {
|
||
const state = getPaneState(pane);
|
||
const paging = state && state.fileListPaging ? state.fileListPaging : null;
|
||
return (paging && paging.enabled) ? paging : null;
|
||
}
|
||
|
||
function setPaneFileListPaging(pane, paging) {
|
||
const normalized = normalizePaneKey(pane);
|
||
const next = (paging && paging.enabled) ? paging : null;
|
||
savePaneState(normalized, { fileListPaging: next });
|
||
if (normalized === normalizePaneKey(window.activePane)) {
|
||
window.currentFileListPaging = next;
|
||
}
|
||
}
|
||
|
||
function shouldUseServerFilePagingForRequest(options = {}) {
|
||
if (options && options.forceLegacy) {
|
||
return false;
|
||
}
|
||
if (window.advancedSearchEnabled) {
|
||
return false;
|
||
}
|
||
const term = String(window.currentSearchTerm || '').trim();
|
||
return term === '';
|
||
}
|
||
|
||
function parseCursorOffset(cursor) {
|
||
const text = String(cursor == null ? '' : cursor).trim();
|
||
if (!/^\d+$/.test(text)) return 0;
|
||
const value = parseInt(text, 10);
|
||
return Number.isFinite(value) && value > 0 ? value : 0;
|
||
}
|
||
|
||
function getSubfoldersForPagingContext(pane, folder, sourceId = '') {
|
||
const paneKey = normalizePaneKey(pane);
|
||
const folderKey = String(folder || 'root');
|
||
const sourceKey = String(sourceId || '').trim();
|
||
|
||
const state = getPaneState(paneKey);
|
||
const stateSource = String(state?.currentSubfoldersSourceId || '').trim();
|
||
const stateFolder = String(state?.currentSubfoldersFolder || '');
|
||
if (stateSource === sourceKey && stateFolder === folderKey && Array.isArray(state?.currentSubfolders)) {
|
||
return state.currentSubfolders;
|
||
}
|
||
|
||
if (paneKey === normalizePaneKey(window.activePane)) {
|
||
const winSource = String(window.currentSubfoldersSourceId || '').trim();
|
||
const winFolder = String(window.currentSubfoldersFolder || '');
|
||
if (winSource === sourceKey && winFolder === folderKey && Array.isArray(window.currentSubfolders)) {
|
||
return window.currentSubfolders;
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function getCombinedPageLayout({ totalFolders, totalFiles, itemsPerPage, targetPage }) {
|
||
const folderCount = Number.isFinite(Number(totalFolders)) ? Math.max(0, Number(totalFolders)) : 0;
|
||
const fileCount = Number.isFinite(Number(totalFiles)) ? Math.max(0, Number(totalFiles)) : 0;
|
||
const itemsRaw = Number(itemsPerPage);
|
||
const pageSize = Number.isFinite(itemsRaw) && itemsRaw > 0 ? Math.max(1, Math.floor(itemsRaw)) : 50;
|
||
const totalRows = folderCount + fileCount;
|
||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize) || 1);
|
||
const targetRaw = Number(targetPage);
|
||
const page = Number.isFinite(targetRaw)
|
||
? Math.max(1, Math.min(totalPages, Math.floor(targetRaw)))
|
||
: 1;
|
||
const startRow = (page - 1) * pageSize;
|
||
const endRow = Math.min(totalRows, startRow + pageSize);
|
||
|
||
const folderStart = Math.min(folderCount, startRow);
|
||
const folderEnd = Math.min(folderCount, endRow);
|
||
const folderCountOnPage = Math.max(0, folderEnd - folderStart);
|
||
|
||
const fileStart = Math.max(0, startRow - folderCount);
|
||
const fileEnd = Math.max(0, endRow - folderCount);
|
||
const fileCountOnPage = Math.max(0, fileEnd - fileStart);
|
||
|
||
return {
|
||
page,
|
||
totalPages,
|
||
pageSize,
|
||
totalRows,
|
||
folderStart,
|
||
folderCount: folderCountOnPage,
|
||
fileStart,
|
||
fileCount: fileCountOnPage
|
||
};
|
||
}
|
||
|
||
function resolveServerPagingCursorForLoad({ pane, folder, sourceId, requestedCursor }) {
|
||
if (requestedCursor != null) {
|
||
return String(requestedCursor);
|
||
}
|
||
if (window.viewMode !== 'table' || window.showInlineFolders === false) {
|
||
return '';
|
||
}
|
||
|
||
const paneKey = normalizePaneKey(pane);
|
||
const state = getPaneState(paneKey);
|
||
const pageRaw = Number(state?.currentPage || window.currentPage || 1);
|
||
const targetPage = Number.isFinite(pageRaw) ? Math.max(1, Math.floor(pageRaw)) : 1;
|
||
if (targetPage <= 1) {
|
||
return '';
|
||
}
|
||
|
||
const subfolders = getSubfoldersForPagingContext(paneKey, folder, sourceId);
|
||
const totalFolders = Array.isArray(subfolders) ? subfolders.length : 0;
|
||
if (totalFolders <= 0) {
|
||
return '';
|
||
}
|
||
const pageSize = getFileListCursorPageSize();
|
||
const fileOffset = Math.max(0, ((targetPage - 1) * pageSize) - totalFolders);
|
||
return String(fileOffset);
|
||
}
|
||
|
||
let __frTreeSourceId = null;
|
||
|
||
function syncActiveSourceToPane(paneKey, sourceId) {
|
||
const targetId = sourceId || getPaneSourceId(paneKey);
|
||
const currentId = getGlobalActiveSourceId();
|
||
if (!targetId || targetId === currentId) return Promise.resolve(false);
|
||
|
||
if (typeof window.__frApplyActiveSource === 'function') {
|
||
return window.__frApplyActiveSource(targetId, { skipEvent: true, origin: 'pane' });
|
||
}
|
||
|
||
const sel = document.getElementById('sourceSelector');
|
||
if (sel) sel.value = targetId;
|
||
try { localStorage.setItem('fr_active_source', targetId); } catch (e) { /* ignore */ }
|
||
return Promise.resolve(true);
|
||
}
|
||
|
||
function refreshTreeForSource(folder, sourceId) {
|
||
const nextId = sourceId || getGlobalActiveSourceId();
|
||
if (!nextId) return;
|
||
if (__frTreeSourceId === nextId) return;
|
||
__frTreeSourceId = nextId;
|
||
try { resetFolderTreeCaches(); } catch (e) { /* ignore */ }
|
||
try { loadFolderTree(folder); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function setPaneHasContent(pane, hasContent) {
|
||
const selector = normalizePaneKey(pane) === 'secondary'
|
||
? '.secondary-pane'
|
||
: '.primary-pane';
|
||
const el = document.querySelector(selector);
|
||
if (!el) return;
|
||
el.classList.toggle('fr-pane-has-content', !!hasContent);
|
||
}
|
||
|
||
function getPaneLayoutMode(targetEl) {
|
||
const pane = targetEl && targetEl.closest ? targetEl.closest('.file-list-pane') : null;
|
||
const width = pane ? pane.getBoundingClientRect().width : window.innerWidth;
|
||
if (width <= 720) return 'narrow';
|
||
if (width <= 980) return 'medium';
|
||
return 'wide';
|
||
}
|
||
|
||
function updatePaneWidthClasses() {
|
||
const panes = document.querySelectorAll('.file-list-pane');
|
||
panes.forEach(pane => {
|
||
const width = pane.getBoundingClientRect().width;
|
||
if (!width) return;
|
||
const mode = width <= 720 ? 'narrow' : (width <= 980 ? 'medium' : 'wide');
|
||
pane.classList.toggle('fr-pane-narrow', mode === 'narrow');
|
||
pane.classList.toggle('fr-pane-medium', mode === 'medium');
|
||
});
|
||
updatePaneStickyClasses();
|
||
}
|
||
|
||
function updatePaneStickyClasses() {
|
||
const panes = document.querySelectorAll('.file-list-pane');
|
||
panes.forEach(pane => pane.classList.remove('fr-pane-sticky'));
|
||
|
||
if (!window.dualPaneEnabled || panes.length < 2) return;
|
||
|
||
const paneList = Array.from(panes);
|
||
const heights = paneList.map(pane => pane.getBoundingClientRect().height || 0);
|
||
const viewport = window.innerHeight || 0;
|
||
const buffer = 24;
|
||
|
||
if (!viewport || heights.every(h => h <= 0)) return;
|
||
|
||
const canStick = heights.map(h => h + buffer <= viewport);
|
||
|
||
if (canStick[0] && canStick[1]) {
|
||
paneList.forEach(pane => pane.classList.add('fr-pane-sticky'));
|
||
return;
|
||
}
|
||
|
||
if (canStick[0] && heights[0] < heights[1]) {
|
||
paneList[0].classList.add('fr-pane-sticky');
|
||
}
|
||
if (canStick[1] && heights[1] < heights[0]) {
|
||
paneList[1].classList.add('fr-pane-sticky');
|
||
}
|
||
}
|
||
|
||
function ensureDualPaneTargetHint() {
|
||
const actions = document.getElementById('fileListActions');
|
||
if (!actions) return null;
|
||
let hint = document.getElementById('dualPaneTargetHint');
|
||
if (!hint) {
|
||
hint = document.createElement('div');
|
||
hint.id = 'dualPaneTargetHint';
|
||
hint.className = 'file-list-dual-hint fr-dual-target-hint';
|
||
actions.appendChild(hint);
|
||
}
|
||
return hint;
|
||
}
|
||
|
||
function updateDualPaneTargetHint() {
|
||
const hint = ensureDualPaneTargetHint();
|
||
if (!hint) return;
|
||
if (!window.dualPaneEnabled || !window.__frPaneState) {
|
||
hint.style.display = 'none';
|
||
return;
|
||
}
|
||
const active = normalizePaneKey(window.activePane);
|
||
const other = active === 'secondary' ? 'primary' : 'secondary';
|
||
const otherFolder = window.__frPaneState?.[other]?.currentFolder || '';
|
||
if (!otherFolder) {
|
||
hint.style.display = 'none';
|
||
return;
|
||
}
|
||
const otherSourceId = window.__frPaneState?.[other]?.sourceId || '';
|
||
const sideLabel = other === 'secondary' ? 'Right' : 'Left';
|
||
const folderLabel = otherFolder === 'root' ? getRootLabel(otherSourceId) : otherFolder;
|
||
hint.textContent = `Target: ${sideLabel} -> ${folderLabel}`;
|
||
hint.title = `Copy/move target is the ${sideLabel.toLowerCase()} pane folder.`;
|
||
hint.style.display = 'inline-flex';
|
||
}
|
||
|
||
function syncFolderStripWithPaneState(paneKey) {
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (!strip) return;
|
||
if (!window.showFoldersInList) {
|
||
strip.innerHTML = '';
|
||
strip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const state = getPaneState(paneKey);
|
||
const sourceId = String((state && state.sourceId) || '');
|
||
const folder = String((state && state.currentFolder) || 'root');
|
||
const subfolders = Array.isArray(state?.currentSubfolders) ? state.currentSubfolders : [];
|
||
const subfoldersSourceId = String(state?.currentSubfoldersSourceId || '');
|
||
const subfoldersFolder = String(state?.currentSubfoldersFolder || '');
|
||
const matchesContext = subfoldersSourceId === sourceId && subfoldersFolder === folder;
|
||
|
||
if (!matchesContext || !subfolders.length) {
|
||
strip.innerHTML = '';
|
||
strip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
renderFolderStripPaged(strip, subfolders, sourceId);
|
||
}
|
||
|
||
function updateInactivePanePlaceholder(inactiveKey) {
|
||
if (!window.dualPaneEnabled) return;
|
||
const container = document.getElementById('fileListContainerSecondary');
|
||
if (!container) return;
|
||
|
||
const ensurePlaceholderElements = () => {
|
||
let hintEl = container.querySelector('.file-list-dual-hint');
|
||
if (!hintEl) {
|
||
let actionsWrap = container.querySelector('.file-list-actions');
|
||
const hasToolbar = actionsWrap && actionsWrap.querySelector('#fileListActions');
|
||
if (!actionsWrap || hasToolbar) {
|
||
actionsWrap = document.createElement('div');
|
||
actionsWrap.className = 'file-list-actions';
|
||
const title = container.querySelector('#fileListTitleSecondary');
|
||
if (title && title.parentNode === container) {
|
||
title.insertAdjacentElement('afterend', actionsWrap);
|
||
} else {
|
||
container.insertBefore(actionsWrap, container.firstChild);
|
||
}
|
||
}
|
||
|
||
hintEl = document.createElement('div');
|
||
hintEl.className = 'file-list-dual-hint';
|
||
hintEl.setAttribute('data-i18n-key', 'dual_pane_secondary_hint');
|
||
hintEl.textContent = t('dual_pane_secondary_hint') || 'Select a folder to open here.';
|
||
actionsWrap.appendChild(hintEl);
|
||
}
|
||
|
||
let emptyEl = container.querySelector('#fileListSecondary .empty-state');
|
||
const listEl = container.querySelector('#fileListSecondary');
|
||
if (!emptyEl && listEl && listEl.childElementCount === 0) {
|
||
listEl.classList.add('file-list-secondary-empty');
|
||
emptyEl = document.createElement('div');
|
||
emptyEl.className = 'empty-state';
|
||
emptyEl.setAttribute('data-i18n-key', 'dual_pane_secondary_empty');
|
||
emptyEl.textContent = t('dual_pane_secondary_empty') || 'Select a folder to open in the right pane.';
|
||
listEl.appendChild(emptyEl);
|
||
}
|
||
|
||
return { hintEl, emptyEl };
|
||
};
|
||
|
||
const state = getPaneState(inactiveKey);
|
||
const paneSourceId = (state && typeof state.sourceId === 'string')
|
||
? state.sourceId.trim()
|
||
: '';
|
||
const labelSourceId = paneSourceId || readStoredPaneSource(inactiveKey);
|
||
let folder = (state && typeof state.currentFolder === 'string')
|
||
? state.currentFolder.trim()
|
||
: '';
|
||
if (!folder) {
|
||
folder = readStoredPaneFolder(inactiveKey, labelSourceId);
|
||
}
|
||
const label = folder === 'root' ? getRootCrumbLabel(labelSourceId) : folder;
|
||
|
||
const ensured = ensurePlaceholderElements();
|
||
|
||
const titleEl = document.getElementById('fileListTitleSecondary');
|
||
if (titleEl && !titleEl.dataset.baseText) {
|
||
titleEl.dataset.baseText = titleEl.textContent || '';
|
||
}
|
||
|
||
const hintEl = ensured.hintEl || container.querySelector('.file-list-dual-hint');
|
||
if (hintEl && !hintEl.dataset.baseText) {
|
||
hintEl.dataset.baseText = hintEl.textContent || (t('dual_pane_secondary_hint') || 'Select a folder to open here.');
|
||
}
|
||
|
||
const emptyEl = ensured.emptyEl || container.querySelector('#fileListSecondary .empty-state');
|
||
if (emptyEl && !emptyEl.dataset.baseText) {
|
||
emptyEl.dataset.baseText = emptyEl.textContent || (t('dual_pane_secondary_empty') || 'Select a folder to open in the right pane.');
|
||
}
|
||
|
||
if (!folder) {
|
||
if (titleEl && titleEl.dataset.baseText) titleEl.textContent = titleEl.dataset.baseText;
|
||
if (hintEl && hintEl.dataset.baseText) hintEl.textContent = hintEl.dataset.baseText;
|
||
if (emptyEl && emptyEl.dataset.baseText) emptyEl.textContent = emptyEl.dataset.baseText;
|
||
refreshSourceBadges();
|
||
return;
|
||
}
|
||
|
||
if (titleEl) {
|
||
const prefix = t('files_in') || 'Files in';
|
||
titleEl.textContent = `${prefix} (${formatBreadcrumbText(folder, labelSourceId)})`;
|
||
}
|
||
if (hintEl) {
|
||
hintEl.textContent = `${hintEl.dataset.baseText} (${label})`;
|
||
}
|
||
if (emptyEl) {
|
||
emptyEl.textContent = `${emptyEl.dataset.baseText} (${label})`;
|
||
}
|
||
refreshSourceBadges();
|
||
}
|
||
|
||
function resetInactivePaneBaseText() {
|
||
const titleEl = document.getElementById('fileListTitleSecondary');
|
||
if (titleEl && titleEl.dataset) {
|
||
delete titleEl.dataset.baseText;
|
||
}
|
||
|
||
const container = document.getElementById('fileListContainerSecondary');
|
||
if (!container) return;
|
||
|
||
const hintEl = container.querySelector('.file-list-dual-hint');
|
||
if (hintEl && hintEl.dataset) {
|
||
delete hintEl.dataset.baseText;
|
||
}
|
||
|
||
const emptyEl = container.querySelector('#fileListSecondary .empty-state');
|
||
if (emptyEl && emptyEl.dataset) {
|
||
delete emptyEl.dataset.baseText;
|
||
}
|
||
}
|
||
|
||
function formatBreadcrumbText(folder, sourceId = '') {
|
||
const path = (typeof folder === 'string' && folder.length) ? folder : 'root';
|
||
const rootLabel = getRootCrumbLabel(sourceId);
|
||
if (path === 'root') return rootLabel;
|
||
const parts = path.split('/').filter(Boolean);
|
||
return [rootLabel, ...parts].join(' › ');
|
||
}
|
||
|
||
function resolvePaneFolderForTitle(paneKey, { fallbackToCurrent = false } = {}) {
|
||
if (!paneKey) return '';
|
||
let folder = '';
|
||
let sourceId = '';
|
||
try {
|
||
const state = getPaneState(paneKey);
|
||
folder = (state && typeof state.currentFolder === 'string')
|
||
? state.currentFolder.trim()
|
||
: '';
|
||
sourceId = (state && typeof state.sourceId === 'string')
|
||
? state.sourceId.trim()
|
||
: '';
|
||
} catch (e) {
|
||
folder = '';
|
||
}
|
||
|
||
if (!folder) {
|
||
const id = sourceId || getPaneSourceId(paneKey);
|
||
folder = readStoredPaneFolder(paneKey, id);
|
||
}
|
||
|
||
if (!folder && fallbackToCurrent) {
|
||
const id = sourceId || getPaneSourceId(paneKey);
|
||
if (id) {
|
||
folder = getLastOpenedFolder(id) || 'root';
|
||
} else {
|
||
folder = window.currentFolder || getLastOpenedFolder(id) || 'root';
|
||
}
|
||
}
|
||
|
||
return folder || '';
|
||
}
|
||
|
||
function refreshPaneTitlesFromState() {
|
||
ensurePaneState();
|
||
let storedActive = null;
|
||
try { storedActive = localStorage.getItem('activePane'); } catch (e) { storedActive = null; }
|
||
|
||
const active = normalizePaneKey(window.activePane || storedActive || 'primary');
|
||
const activeFolder = resolvePaneFolderForTitle(active, { fallbackToCurrent: true });
|
||
if (activeFolder) {
|
||
updateBreadcrumbTitle(activeFolder);
|
||
}
|
||
|
||
if (window.dualPaneEnabled) {
|
||
const other = active === 'secondary' ? 'primary' : 'secondary';
|
||
updateInactivePanePlaceholder(other);
|
||
updateDualPaneTargetHint();
|
||
}
|
||
refreshSourceBadges();
|
||
}
|
||
|
||
// Default folder display settings from localStorage
|
||
try {
|
||
const storedStrip = localStorage.getItem('showFoldersInList');
|
||
const storedInline = localStorage.getItem('showInlineFolders');
|
||
|
||
window.showFoldersInList = storedStrip === null ? true : storedStrip === 'true';
|
||
window.showInlineFolders = storedInline === null ? true : storedInline === 'true';
|
||
} catch (e) {
|
||
// if localStorage blows up, fall back to both enabled
|
||
window.showFoldersInList = true;
|
||
window.showInlineFolders = true;
|
||
}
|
||
|
||
// Dual pane mode (UI-only scaffolding)
|
||
function setActivePane(pane, opts) {
|
||
ensurePaneState();
|
||
const next = normalizePaneKey(pane);
|
||
const current = normalizePaneKey(window.activePane);
|
||
|
||
if (current !== next) {
|
||
syncPaneStateFromGlobals(current);
|
||
|
||
const activeContainer = document.getElementById('fileListContainer');
|
||
const inactiveContainer = document.getElementById('fileListContainerSecondary');
|
||
if (activeContainer && inactiveContainer) {
|
||
activeContainer.id = 'fileListContainerSecondary';
|
||
inactiveContainer.id = 'fileListContainer';
|
||
}
|
||
|
||
const activeTitle = document.getElementById('fileListTitle');
|
||
const inactiveTitle = document.getElementById('fileListTitleSecondary');
|
||
if (activeTitle && inactiveTitle) {
|
||
activeTitle.id = 'fileListTitleSecondary';
|
||
inactiveTitle.id = 'fileListTitle';
|
||
}
|
||
|
||
const activeList = document.getElementById('fileList');
|
||
const inactiveList = document.getElementById('fileListSecondary');
|
||
if (activeList && inactiveList) {
|
||
activeList.id = 'fileListSecondary';
|
||
inactiveList.id = 'fileList';
|
||
}
|
||
}
|
||
|
||
window.activePane = next;
|
||
const state = getPaneState(next);
|
||
const paneSourceId = getPaneSourceId(next);
|
||
const globalSourceId = getGlobalActiveSourceId();
|
||
if (__frTreeSourceId === null) {
|
||
__frTreeSourceId = globalSourceId || paneSourceId || null;
|
||
}
|
||
if (!state.currentFolder) {
|
||
const fallbackFolder = readStoredPaneFolder(next, paneSourceId) || getLastOpenedFolder(paneSourceId) || 'root';
|
||
savePaneState(next, { currentFolder: fallbackFolder });
|
||
state.currentFolder = fallbackFolder;
|
||
}
|
||
if (paneSourceId && paneSourceId !== globalSourceId) {
|
||
syncActiveSourceToPane(next, paneSourceId).then(ok => {
|
||
if (ok) {
|
||
const targetFolder = state.currentFolder || window.currentFolder || 'root';
|
||
const treeWillReload = __frTreeSourceId !== paneSourceId;
|
||
if (treeWillReload && state.hasLoaded && !state.needsReload) {
|
||
window.__frSkipListReload = {
|
||
folder: targetFolder,
|
||
sourceId: paneSourceId
|
||
};
|
||
}
|
||
refreshTreeForSource(targetFolder, paneSourceId);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
const activeContainer = document.getElementById('fileListContainer');
|
||
const inactiveContainer = document.getElementById('fileListContainerSecondary');
|
||
if (activeContainer) activeContainer.classList.add('fr-pane-active');
|
||
if (inactiveContainer) inactiveContainer.classList.remove('fr-pane-active');
|
||
|
||
const actions = document.getElementById('fileListActions');
|
||
if (actions && activeContainer) {
|
||
const title = activeContainer.querySelector('#fileListTitle');
|
||
if (title && title.parentNode === activeContainer) {
|
||
title.insertAdjacentElement('afterend', actions);
|
||
} else {
|
||
activeContainer.insertBefore(actions, activeContainer.firstChild);
|
||
}
|
||
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) {
|
||
actions.parentNode.insertBefore(strip, actions);
|
||
}
|
||
}
|
||
|
||
syncGlobalsFromPaneState(next);
|
||
syncFolderStripWithPaneState(next);
|
||
if (state.currentFolder) {
|
||
updateBreadcrumbTitle(state.currentFolder);
|
||
try { syncFolderTreeSelection(state.currentFolder); } catch (e) { /* ignore */ }
|
||
}
|
||
updateEncryptedFolderBanner(state.currentFolder || window.currentFolder || 'root');
|
||
refreshFileSummaryForPane(next);
|
||
if (state.needsReload && state.currentFolder) {
|
||
savePaneState(next, { needsReload: false });
|
||
state.needsReload = false;
|
||
loadFileList(state.currentFolder);
|
||
} else if (
|
||
window.dualPaneEnabled &&
|
||
!state.hasLoaded &&
|
||
state.currentFolder &&
|
||
!(opts && opts.skipAutoLoad)
|
||
) {
|
||
// Lazy-load the pane's last folder only when it becomes active.
|
||
loadFileList(state.currentFolder);
|
||
}
|
||
|
||
setPaneHasContent(next, !!state.hasLoaded);
|
||
applyEncryptedFolderUiRestrictions();
|
||
updateFileActionButtons();
|
||
updatePaneWidthClasses();
|
||
updateDualPaneTargetHint();
|
||
updateInactivePanePlaceholder(next === 'secondary' ? 'primary' : 'secondary');
|
||
refreshSourceBadges();
|
||
scheduleFolderIconRepair({ force: true, skipStats: true });
|
||
scheduleInactivePaneFolderIconRepair();
|
||
|
||
if (!opts || !opts.skipStore) {
|
||
try { localStorage.setItem('activePane', next); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
function applyDualPaneMode(force) {
|
||
let enabled = false;
|
||
try {
|
||
enabled = (typeof force === 'boolean')
|
||
? force
|
||
: localStorage.getItem('dualPaneMode') === 'true';
|
||
} catch (e) { /* ignore */ }
|
||
window.dualPaneEnabled = enabled;
|
||
|
||
const shell = document.getElementById('fileListShell');
|
||
if (shell) shell.classList.toggle('dual-pane', enabled);
|
||
|
||
const storedActive = (function () {
|
||
try { return localStorage.getItem('activePane'); } catch (e) { return null; }
|
||
})();
|
||
if (enabled) {
|
||
setActivePane(storedActive === 'secondary' ? 'secondary' : 'primary', { skipStore: true, skipAutoLoad: true });
|
||
} else {
|
||
setActivePane('primary', { skipStore: true, skipAutoLoad: true });
|
||
}
|
||
|
||
const primaryPane = document.querySelector('.file-list-pane.primary-pane');
|
||
const secondaryPane = document.querySelector('.file-list-pane.secondary-pane');
|
||
if (enabled) {
|
||
if (primaryPane) primaryPane.style.display = '';
|
||
if (secondaryPane) secondaryPane.style.display = '';
|
||
} else {
|
||
if (primaryPane) primaryPane.style.display = '';
|
||
if (secondaryPane) secondaryPane.style.display = 'none';
|
||
if (primaryPane) primaryPane.classList.remove('fr-pane-active');
|
||
if (secondaryPane) secondaryPane.classList.remove('fr-pane-active');
|
||
}
|
||
updatePaneWidthClasses();
|
||
updateDualPaneTargetHint();
|
||
}
|
||
|
||
window.applyDualPaneMode = applyDualPaneMode;
|
||
|
||
try {
|
||
window.addEventListener('filerise:i18n-applied', () => {
|
||
resetInactivePaneBaseText();
|
||
refreshPaneTitlesFromState();
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
|
||
try {
|
||
window.addEventListener('filerise:source-change', (e) => {
|
||
const id = e?.detail?.id ? String(e.detail.id) : '';
|
||
if (!id) return;
|
||
ensurePaneState();
|
||
const activeKey = normalizePaneKey(window.activePane);
|
||
const state = getPaneState(activeKey);
|
||
if (!state) return;
|
||
const prev = state.sourceId || '';
|
||
if (prev === id) {
|
||
const folder = state.currentFolder || window.currentFolder || 'root';
|
||
refreshTreeForSource(folder, id);
|
||
return;
|
||
}
|
||
savePaneState(activeKey, {
|
||
sourceId: id,
|
||
currentSubfolders: [],
|
||
currentSubfoldersSourceId: '',
|
||
currentSubfoldersFolder: '',
|
||
currentFolderCaps: null,
|
||
selectedFolderCaps: null
|
||
});
|
||
if (activeKey === normalizePaneKey(window.activePane)) {
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) {
|
||
strip.innerHTML = '';
|
||
strip.style.display = 'none';
|
||
}
|
||
}
|
||
const storedFolder = readStoredPaneFolder(activeKey, id) || getLastOpenedFolder(id) || 'root';
|
||
savePaneState(activeKey, { currentFolder: storedFolder });
|
||
const folder = storedFolder || state.currentFolder || 'root';
|
||
const treeContainer = document.getElementById('folderTreeContainer');
|
||
const treeWillReload = !!treeContainer && __frTreeSourceId !== id;
|
||
refreshTreeForSource(folder, id);
|
||
// Avoid double-loading: refreshTreeForSource triggers loadFolderTree → selectFolder → loadFileList.
|
||
if (!treeContainer || !treeWillReload) {
|
||
loadFileList(folder, { pane: activeKey }).catch(() => {});
|
||
}
|
||
refreshSourceBadges();
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// Activate pane on click
|
||
document.addEventListener('click', (e) => {
|
||
if (!window.dualPaneEnabled) return;
|
||
const pane = e.target && e.target.closest
|
||
? e.target.closest('.file-list-pane')
|
||
: null;
|
||
if (!pane) return;
|
||
setActivePane(pane.classList.contains('secondary-pane') ? 'secondary' : 'primary');
|
||
}, true);
|
||
|
||
function isPaneDropTargetAllowed(target) {
|
||
if (!target || !target.closest) return true;
|
||
return !target.closest('.folder-item, .folder-row, .folder-option, .folder-strip-container, .folder-tree');
|
||
}
|
||
|
||
function paneDragOverHandler(e) {
|
||
if (!window.dualPaneEnabled) return;
|
||
if (!isPaneDropTargetAllowed(e.target)) return;
|
||
e.preventDefault();
|
||
e.currentTarget.classList.add('fr-pane-drop-target');
|
||
}
|
||
|
||
function paneDragLeaveHandler(e) {
|
||
e.currentTarget.classList.remove('fr-pane-drop-target');
|
||
}
|
||
|
||
async function maybeRestoreInactivePane(activePaneKey) {
|
||
if (!window.dualPaneEnabled || __dualPaneRestoreRunning) return;
|
||
ensurePaneState();
|
||
|
||
const active = normalizePaneKey(activePaneKey || window.activePane);
|
||
const other = active === 'secondary' ? 'primary' : 'secondary';
|
||
const state = window.__frPaneState?.[other];
|
||
const otherSourceId = (state && state.sourceId) || readStoredPaneSource(other) || '';
|
||
const otherFolder = (state && state.currentFolder) || readStoredPaneFolder(other, otherSourceId);
|
||
|
||
if (!otherFolder || (state && state.hasLoaded)) return;
|
||
|
||
__dualPaneRestoreRunning = true;
|
||
try {
|
||
if (normalizePaneKey(window.activePane) !== other) {
|
||
setActivePane(other, { skipStore: true });
|
||
}
|
||
await loadFileList(otherFolder, { pane: other, skipFallback: true });
|
||
} catch (e) { /* ignore */ }
|
||
finally {
|
||
if (normalizePaneKey(window.activePane) !== active) {
|
||
setActivePane(active, { skipStore: true });
|
||
}
|
||
__dualPaneRestoreRunning = false;
|
||
}
|
||
}
|
||
|
||
function pruneMovedFolderFromInactivePane(sourceFolder, sourceParent, sourceId = '') {
|
||
if (!window.dualPaneEnabled || !sourceFolder || !window.__frPaneState) return;
|
||
|
||
const inactiveKey = normalizePaneKey(window.activePane) === 'secondary'
|
||
? 'primary'
|
||
: 'secondary';
|
||
const state = window.__frPaneState[inactiveKey];
|
||
if (!state || state.currentFolder !== sourceParent) return;
|
||
if (sourceId && state.sourceId && state.sourceId !== sourceId) return;
|
||
|
||
if (Array.isArray(state.currentSubfolders)) {
|
||
state.currentSubfolders = state.currentSubfolders.filter(sf => sf.full !== sourceFolder);
|
||
}
|
||
|
||
const list = document.getElementById('fileListSecondary');
|
||
if (!list) return;
|
||
|
||
try {
|
||
const row = list.querySelector(`tr.folder-row[data-folder="${CSS.escape(sourceFolder)}"]`);
|
||
if (row) row.remove();
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function getPaneListForKey(paneKey) {
|
||
const pane = document.querySelector(
|
||
paneKey === 'secondary' ? '.file-list-pane.secondary-pane' : '.file-list-pane.primary-pane'
|
||
);
|
||
if (!pane) return null;
|
||
return pane.querySelector('#fileList, #fileListSecondary') || pane;
|
||
}
|
||
|
||
function pruneMovedFilesFromInactivePane(sourceFolder, movedFiles, sourceId = '') {
|
||
if (!window.dualPaneEnabled || !sourceFolder || !window.__frPaneState) return;
|
||
|
||
const names = Array.isArray(movedFiles)
|
||
? movedFiles.map(f => {
|
||
if (!f) return '';
|
||
if (typeof f === 'string') return f;
|
||
if (typeof f === 'object') {
|
||
return String(f.name || f.file || f.filename || f.fileName || '');
|
||
}
|
||
return String(f);
|
||
}).map(n => String(n || '').trim()).filter(Boolean)
|
||
: [];
|
||
if (!names.length) return;
|
||
|
||
const nameSet = new Set();
|
||
names.forEach(name => {
|
||
const raw = String(name || '').trim();
|
||
if (!raw) return;
|
||
nameSet.add(raw);
|
||
const decoded = decodeHtmlEntities(raw);
|
||
if (decoded && decoded !== raw) {
|
||
nameSet.add(decoded);
|
||
}
|
||
});
|
||
['primary', 'secondary'].forEach(paneKey => {
|
||
const state = window.__frPaneState[paneKey];
|
||
if (!state || state.currentFolder !== sourceFolder) return;
|
||
if (sourceId && state.sourceId && state.sourceId !== sourceId) return;
|
||
|
||
if (Array.isArray(state.fileData)) {
|
||
state.fileData = state.fileData.filter(f => f && !nameSet.has(f.name));
|
||
}
|
||
|
||
const list = getPaneListForKey(paneKey);
|
||
if (!list) return;
|
||
|
||
list.querySelectorAll('tr[data-file-name], .gallery-card[data-file-name]').forEach(row => {
|
||
const raw = row.getAttribute('data-file-name') || '';
|
||
const decoded = decodeHtmlEntities(raw);
|
||
if (nameSet.has(raw) || nameSet.has(decoded)) {
|
||
row.remove();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function paneDropHandler(e) {
|
||
if (!window.dualPaneEnabled) return;
|
||
if (!isPaneDropTargetAllowed(e.target)) return;
|
||
e.preventDefault();
|
||
e.currentTarget.classList.remove('fr-pane-drop-target');
|
||
|
||
const paneKey = e.currentTarget.classList.contains('secondary-pane') ? 'secondary' : 'primary';
|
||
const paneState = getPaneState(paneKey);
|
||
const targetFolder = paneState?.currentFolder || '';
|
||
if (!targetFolder) return;
|
||
|
||
let dragData = null;
|
||
try {
|
||
const raw = e.dataTransfer.getData('application/json') || '{}';
|
||
dragData = JSON.parse(raw);
|
||
} catch (err) {
|
||
dragData = null;
|
||
}
|
||
|
||
if (!dragData) return;
|
||
|
||
const sourceId = String(dragData.sourceId || dragData.sourceSourceId || '').trim();
|
||
const destSourceId = getPaneSourceId(paneKey) || getGlobalActiveSourceId();
|
||
const crossSource = sourceId && destSourceId && sourceId !== destSourceId;
|
||
|
||
const scheduleRepair = () => {
|
||
try {
|
||
const kick = () => { try { repairBlankFolderIcons({ force: true }); } catch (e) {} };
|
||
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
|
||
setTimeout(kick, 80);
|
||
setTimeout(kick, 250);
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
if (dragData.dragType === 'folder' && dragData.folder) {
|
||
const sourceFolder = String(dragData.folder || '').trim();
|
||
if (!sourceFolder || sourceFolder === 'root') return;
|
||
if (!crossSource && (targetFolder === sourceFolder || targetFolder.startsWith(sourceFolder + '/'))) {
|
||
showToast(t('invalid_destination'), 'warning');
|
||
return;
|
||
}
|
||
|
||
if (normalizePaneKey(window.activePane) !== paneKey) {
|
||
setActivePane(paneKey);
|
||
}
|
||
|
||
const sourceParent = dragData.sourceFolder || parentFolderOf(sourceFolder);
|
||
try {
|
||
await runTransferJob({
|
||
kind: 'folder_move',
|
||
payload: {
|
||
source: sourceFolder,
|
||
destination: targetFolder,
|
||
sourceId,
|
||
destSourceId
|
||
},
|
||
progress: {
|
||
action: 'Moving',
|
||
itemCount: 1,
|
||
itemLabel: 'folder',
|
||
bytesKnown: false,
|
||
indeterminate: true,
|
||
source: sourceFolder,
|
||
destination: targetFolder
|
||
}
|
||
});
|
||
showToast(buildMoveFolderSuccessToast(sourceFolder, targetFolder), 'success');
|
||
if (crossSource) {
|
||
if (sourceParent) invalidateFolderStats([sourceParent], sourceId);
|
||
invalidateFolderStats([targetFolder], destSourceId);
|
||
loadFileList(targetFolder, { pane: paneKey }).finally(scheduleRepair);
|
||
} else {
|
||
const statSourceId = sourceId || destSourceId;
|
||
invalidateFolderStats([sourceParent, targetFolder], statSourceId);
|
||
await syncTreeAfterFolderMove(sourceFolder, targetFolder);
|
||
scheduleRepair();
|
||
}
|
||
markPaneNeedsReloadForFolder(sourceParent, sourceId);
|
||
pruneMovedFolderFromInactivePane(sourceFolder, sourceParent, sourceId);
|
||
} catch (err) {
|
||
if (err && err.cancelled) {
|
||
showToast(t('transfer_cancelled') || 'Transfer cancelled.', 'info');
|
||
return;
|
||
}
|
||
const errMsg = err && err.message ? err.message : t('move_folder_error_default');
|
||
console.error('Error moving folder:', err);
|
||
showToast(t('move_folder_error_detail', { error: errMsg }), 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const files = Array.isArray(dragData.files) ? dragData.files : [];
|
||
if (!files.length) return;
|
||
|
||
const sourceFolder = dragData.sourceFolder || window.currentFolder || 'root';
|
||
if (!crossSource && targetFolder === sourceFolder) {
|
||
showToast(t('already_in_target_folder'), 'info');
|
||
return;
|
||
}
|
||
|
||
if (normalizePaneKey(window.activePane) !== paneKey) {
|
||
setActivePane(paneKey);
|
||
}
|
||
|
||
const totals = {
|
||
totalBytes: Number.isFinite(dragData.totalBytes) ? dragData.totalBytes : 0,
|
||
bytesKnown: dragData.bytesKnown === true,
|
||
itemCount: files.length
|
||
};
|
||
try {
|
||
await runTransferJob({
|
||
kind: 'file_move',
|
||
payload: {
|
||
source: sourceFolder,
|
||
files,
|
||
destination: targetFolder,
|
||
sourceId,
|
||
destSourceId,
|
||
totalBytes: totals.totalBytes
|
||
},
|
||
progress: {
|
||
action: 'Moving',
|
||
itemCount: totals.itemCount,
|
||
itemLabel: totals.itemCount === 1 ? 'file' : 'files',
|
||
totalBytes: totals.totalBytes,
|
||
bytesKnown: totals.bytesKnown,
|
||
source: sourceFolder,
|
||
destination: targetFolder
|
||
}
|
||
});
|
||
const destLabel = targetFolder || t('root_folder');
|
||
const movedNames = files.map(f => {
|
||
if (typeof f === 'string') return String(f || '').trim();
|
||
if (f && typeof f === 'object') {
|
||
return String(f.name || f.file || f.filename || f.fileName || '').trim();
|
||
}
|
||
return '';
|
||
}).filter(Boolean);
|
||
if (movedNames.length === 1) {
|
||
showToast(t('move_file_success_to', { name: movedNames[0], folder: destLabel }), 'success');
|
||
} else {
|
||
showToast(t('move_files_success_to', { count: movedNames.length || files.length, folder: destLabel }), 'success');
|
||
}
|
||
if (crossSource) {
|
||
invalidateFolderStats([sourceFolder], sourceId);
|
||
invalidateFolderStats([targetFolder], destSourceId);
|
||
} else {
|
||
const statSourceId = sourceId || destSourceId;
|
||
invalidateFolderStats([sourceFolder, targetFolder], statSourceId);
|
||
}
|
||
loadFileList(targetFolder, { pane: paneKey }).finally(scheduleRepair);
|
||
const activeSourceId = getGlobalActiveSourceId();
|
||
if (!destSourceId || destSourceId === activeSourceId) refreshFolderIcon(targetFolder);
|
||
if (!sourceId || sourceId === activeSourceId) refreshFolderIcon(sourceFolder);
|
||
markPaneNeedsReloadForFolder(sourceFolder, sourceId);
|
||
pruneMovedFilesFromInactivePane(sourceFolder, files, sourceId);
|
||
} catch (err) {
|
||
if (err && err.cancelled) {
|
||
showToast(t('transfer_cancelled') || 'Transfer cancelled.', 'info');
|
||
return;
|
||
}
|
||
const errMsg = err && err.message ? err.message : t('unknown_error');
|
||
console.error('Error moving files:', err);
|
||
showToast(t('move_files_error', { error: errMsg }), 'error');
|
||
}
|
||
}
|
||
|
||
function bindPaneDropTargets() {
|
||
const panes = document.querySelectorAll('.file-list-pane');
|
||
panes.forEach(pane => {
|
||
if (pane.dataset.dropBound === '1') return;
|
||
pane.dataset.dropBound = '1';
|
||
pane.addEventListener('dragover', paneDragOverHandler);
|
||
pane.addEventListener('dragleave', paneDragLeaveHandler);
|
||
pane.addEventListener('drop', paneDropHandler);
|
||
});
|
||
}
|
||
|
||
// Apply initial dual-pane state once DOM is ready
|
||
try { applyDualPaneMode(); } catch (e) { /* ignore */ }
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => applyDualPaneMode(), { once: true });
|
||
}
|
||
if (typeof window.__frDualPanePending !== 'undefined') {
|
||
try { applyDualPaneMode(!!window.__frDualPanePending); } catch (e) { /* ignore */ }
|
||
delete window.__frDualPanePending;
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => bindPaneDropTargets(), { once: true });
|
||
} else {
|
||
bindPaneDropTargets();
|
||
}
|
||
|
||
try {
|
||
window.addEventListener('resize', debounce(updatePaneWidthClasses, 150));
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// Global flag for advanced search mode.
|
||
window.advancedSearchEnabled = false;
|
||
|
||
// ---- Inline folder selection helpers --------------------------
|
||
function getFolderCheckboxes() {
|
||
return Array.from(document.querySelectorAll('#fileList .folder-checkbox'));
|
||
}
|
||
|
||
function getFileCheckboxes() {
|
||
return Array.from(document.querySelectorAll('#fileList .file-checkbox'));
|
||
}
|
||
|
||
function getActiveSelectedFileNames() {
|
||
return Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'))
|
||
.map(cb => cb.value);
|
||
}
|
||
|
||
function getActiveSelectedFileObjects() {
|
||
const selected = getActiveSelectedFileNames();
|
||
if (!selected.length) return [];
|
||
const selectedSet = new Set(selected);
|
||
return (Array.isArray(fileData) ? fileData : [])
|
||
.filter(f => selectedSet.has(escapeHTML(f.name)));
|
||
}
|
||
|
||
function clearFolderSelections() {
|
||
getFolderCheckboxes().forEach(cb => {
|
||
if (cb.checked) {
|
||
cb.checked = false;
|
||
updateRowHighlight(cb);
|
||
}
|
||
});
|
||
window.selectedInlineFolder = null;
|
||
window.selectedFolderCaps = null;
|
||
savePaneState(normalizePaneKey(window.activePane), { selectedFolderCaps: null });
|
||
}
|
||
|
||
function clearFileSelections() {
|
||
getFileCheckboxes().forEach(cb => {
|
||
if (cb.checked) {
|
||
cb.checked = false;
|
||
updateRowHighlight(cb);
|
||
}
|
||
});
|
||
}
|
||
|
||
function getSelectedFolderPath() {
|
||
const cb = document.querySelector('#fileList .folder-checkbox:checked');
|
||
return cb ? cb.dataset.folder || null : null;
|
||
}
|
||
|
||
function getSelectedTreeFolderPath() {
|
||
const tree = document.getElementById('folderTreeContainer');
|
||
const opt = tree ? tree.querySelector('.folder-option.selected') : document.querySelector('.folder-option.selected');
|
||
if (!opt) return null;
|
||
const folder = opt.getAttribute('data-folder') || '';
|
||
return folder || null;
|
||
}
|
||
|
||
async function refreshSelectedFolderCaps(folder) {
|
||
if (!folder) {
|
||
window.selectedFolderCaps = null;
|
||
savePaneState(normalizePaneKey(window.activePane), { selectedFolderCaps: null });
|
||
updateFileActionButtons();
|
||
return;
|
||
}
|
||
const pane = normalizePaneKey(window.activePane);
|
||
const sourceId = getPaneSourceId(pane);
|
||
window.selectedFolderCaps = null;
|
||
try {
|
||
const caps = await fetchFolderCaps(folder, sourceId);
|
||
window.selectedFolderCaps = caps || null;
|
||
savePaneState(pane, { selectedFolderCaps: window.selectedFolderCaps });
|
||
} catch (e) {
|
||
window.selectedFolderCaps = null;
|
||
savePaneState(pane, { selectedFolderCaps: null });
|
||
}
|
||
updateFileActionButtons();
|
||
}
|
||
|
||
function isTextEntryTarget(target) {
|
||
const tag = target?.tagName ? target.tagName.toLowerCase() : '';
|
||
return tag === 'input' || tag === 'textarea' || tag === 'select' || target?.isContentEditable;
|
||
}
|
||
|
||
function markPaneNeedsReloadForFolder(folder, sourceId = '') {
|
||
if (!window.dualPaneEnabled || !window.__frPaneState || !folder) return;
|
||
const active = normalizePaneKey(window.activePane);
|
||
['primary', 'secondary'].forEach(pane => {
|
||
if (pane === active) return;
|
||
const state = window.__frPaneState[pane];
|
||
if (state && state.currentFolder === folder) {
|
||
if (sourceId && state.sourceId && state.sourceId !== sourceId) return;
|
||
state.needsReload = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleFolderCheckboxChange(cb) {
|
||
if (!cb) return;
|
||
|
||
// Enforce single-select folders and mutually exclusive with files
|
||
if (cb.checked) {
|
||
clearFileSelections();
|
||
getFolderCheckboxes().forEach(other => {
|
||
if (other !== cb && other.checked) {
|
||
other.checked = false;
|
||
updateRowHighlight(other);
|
||
}
|
||
});
|
||
window.selectedInlineFolder = cb.dataset.folder || null;
|
||
} else {
|
||
window.selectedInlineFolder = null;
|
||
}
|
||
|
||
updateRowHighlight(cb);
|
||
updateFileActionButtons();
|
||
refreshSelectedFolderCaps(window.selectedInlineFolder);
|
||
}
|
||
|
||
function resetPagingForFolderNavigation(pane) {
|
||
const paneKey = normalizePaneKey(pane || window.activePane);
|
||
savePaneState(paneKey, { currentPage: 1 });
|
||
setPaneFileListPaging(paneKey, null);
|
||
if (paneKey === normalizePaneKey(window.activePane)) {
|
||
window.currentPage = 1;
|
||
}
|
||
}
|
||
|
||
function setCurrentFolderContext(folder, opts = {}) {
|
||
if (!folder) return;
|
||
const pane = normalizePaneKey(opts.pane || window.activePane);
|
||
if (opts.resetPage === true) {
|
||
resetPagingForFolderNavigation(pane);
|
||
}
|
||
window.currentFolder = folder;
|
||
setLastOpenedFolder(folder, getPaneSourceId(pane));
|
||
updateBreadcrumbTitle(folder);
|
||
savePaneState(pane, { currentFolder: folder });
|
||
}
|
||
|
||
function clampRowHeight(v) {
|
||
const n = parseInt(v, 10);
|
||
if (!Number.isFinite(n)) return 44;
|
||
return Math.max(20, Math.min(60, n));
|
||
}
|
||
|
||
function applyRowHeight(v) {
|
||
const h = clampRowHeight(v);
|
||
document.documentElement.style.setProperty('--file-row-height', `${h}px`);
|
||
localStorage.setItem('rowHeight', h);
|
||
|
||
// compact marker for tiny rows
|
||
if (h <= 32) {
|
||
document.documentElement.setAttribute('data-row-compact', '1');
|
||
} else {
|
||
document.documentElement.removeAttribute('data-row-compact');
|
||
}
|
||
|
||
try { syncFolderIconSizeToRowHeight(); } catch (e) {}
|
||
return h;
|
||
}
|
||
|
||
function getGalleryMaxColumns() {
|
||
const w = window.innerWidth;
|
||
if (w < 600) return 1;
|
||
if (w < 1024) return 3;
|
||
if (w < 1440) return 4;
|
||
return 6;
|
||
}
|
||
|
||
function clampGalleryColumns(v) {
|
||
const max = getGalleryMaxColumns();
|
||
const n = parseInt(v, 10);
|
||
const base = Number.isFinite(n) ? n : 3;
|
||
return Math.max(1, Math.min(max, base));
|
||
}
|
||
|
||
// Apply stored row height immediately so the table uses it on first render.
|
||
applyRowHeight(localStorage.getItem('rowHeight') || '44');
|
||
|
||
// Base gallery column value (clamped per screen width)
|
||
window.galleryColumns = clampGalleryColumns(localStorage.getItem('galleryColumns') || window.galleryColumns || 3);
|
||
|
||
// --- Folder stats cache (for isEmpty.php) ---
|
||
const _folderStatsCache = window.__FR_FOLDER_STATS_CACHE || new Map();
|
||
const _folderStatsInflight = window.__FR_FOLDER_STATS_INFLIGHT || new Map();
|
||
const _folderSummaryCache = window.__FR_FOLDER_SUMMARY_CACHE || new Map();
|
||
window.__FR_FOLDER_STATS_CACHE = _folderStatsCache;
|
||
window.__FR_FOLDER_STATS_INFLIGHT = _folderStatsInflight;
|
||
window.__FR_FOLDER_SUMMARY_CACHE = _folderSummaryCache;
|
||
const MAX_CONCURRENT_FOLDER_STATS_REQS = 6;
|
||
let _activeFolderStatsReqs = 0;
|
||
const _folderStatsQueue = [];
|
||
|
||
function folderCacheKey(folder, sourceId) {
|
||
const sid = sourceId ? String(sourceId) : '';
|
||
return sid ? `${sid}::${folder}` : folder;
|
||
}
|
||
|
||
function getFolderStatsTimeout(sourceId = '') {
|
||
const type = String(getSourceTypeById(sourceId || getGlobalActiveSourceId()) || '').toLowerCase();
|
||
if (type && type !== 'local') return 6000;
|
||
return 2500;
|
||
}
|
||
|
||
function _runFolderStats(url, timeoutMs) {
|
||
return new Promise(resolve => {
|
||
const start = () => {
|
||
_activeFolderStatsReqs++;
|
||
_fetchJSONWithTimeout(url, timeoutMs || 2500)
|
||
.then(resolve)
|
||
.finally(() => {
|
||
_activeFolderStatsReqs--;
|
||
const next = _folderStatsQueue.shift();
|
||
if (next) next();
|
||
});
|
||
};
|
||
|
||
if (_activeFolderStatsReqs < MAX_CONCURRENT_FOLDER_STATS_REQS) start();
|
||
else _folderStatsQueue.push(start);
|
||
});
|
||
}
|
||
|
||
function fetchFolderStats(folder, sourceId = '') {
|
||
if (!folder) return Promise.resolve(null);
|
||
if (isSlowRemoteSource(sourceId)) return Promise.resolve(null);
|
||
|
||
const key = folderCacheKey(folder, sourceId);
|
||
if (_folderStatsCache.has(key)) {
|
||
return Promise.resolve(_folderStatsCache.get(key));
|
||
}
|
||
if (_folderStatsInflight.has(key)) {
|
||
return _folderStatsInflight.get(key);
|
||
}
|
||
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
const url = withBase(`/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}${sourceParam}&t=${Date.now()}`);
|
||
const timeoutMs = getFolderStatsTimeout(sourceId);
|
||
const p = _runFolderStats(url, timeoutMs).then(data => {
|
||
const payload = (data && !data.__fr_err) ? data : { folders: 0, files: 0 };
|
||
const stillInflight = _folderStatsInflight.get(key) === p;
|
||
if (stillInflight) {
|
||
_folderStatsInflight.delete(key);
|
||
}
|
||
// If this was a transient network/server failure, don't poison the cache.
|
||
if (data && data.__fr_err) {
|
||
return payload;
|
||
}
|
||
if (stillInflight) {
|
||
_folderStatsCache.set(key, payload);
|
||
}
|
||
return payload;
|
||
}).catch(() => {
|
||
if (_folderStatsInflight.get(key) === p) {
|
||
_folderStatsInflight.delete(key);
|
||
}
|
||
return { folders: 0, files: 0 };
|
||
});
|
||
|
||
_folderStatsInflight.set(key, p);
|
||
return p;
|
||
}
|
||
|
||
function fetchFolderSummaryStats(folder, depthOverride, sourceId = '') {
|
||
if (!folder) return Promise.resolve(null);
|
||
if (isSlowRemoteSource(sourceId)) {
|
||
return Promise.resolve(null);
|
||
}
|
||
|
||
const depthSetting = Number.isFinite(depthOverride) ? depthOverride : getFileListSummaryDepth();
|
||
const depth = Math.max(MIN_FILE_LIST_SUMMARY_DEPTH, Math.min(MAX_FILE_LIST_SUMMARY_DEPTH, depthSetting));
|
||
const depthKey = depth > 0 ? String(depth) : 'u';
|
||
const baseKey = folderCacheKey(folder, sourceId);
|
||
const cacheKey = `${baseKey}::${depthKey}`;
|
||
|
||
if (_folderSummaryCache.has(cacheKey)) {
|
||
return _folderSummaryCache.get(cacheKey);
|
||
}
|
||
|
||
const depthParam = depth > 0 ? `&depth=${depth}` : '';
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
const url = withBase(`/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&deep=1${depthParam}${sourceParam}&t=${Date.now()}`);
|
||
const p = _fetchJSONWithTimeout(url, 6000).then(data => {
|
||
if (data && data.__fr_err) {
|
||
_folderSummaryCache.delete(cacheKey);
|
||
return null;
|
||
}
|
||
return data || null;
|
||
});
|
||
|
||
_folderSummaryCache.set(cacheKey, p);
|
||
return p;
|
||
}
|
||
|
||
function clearFolderSummaryCache(folder, sourceId = '') {
|
||
if (!folder) return;
|
||
const prefix = `${folderCacheKey(folder, sourceId)}::`;
|
||
for (const key of _folderSummaryCache.keys()) {
|
||
if (key === folder || key.startsWith(prefix)) {
|
||
_folderSummaryCache.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Folder "peek" cache (first few child folders/files) ---
|
||
const FOLDER_PEEK_MAX_ITEMS = 6;
|
||
const _folderPeekCache = new Map();
|
||
|
||
// Listen for invalidation events from drag/drop, etc.
|
||
window.addEventListener('folderStatsInvalidated', (e) => {
|
||
const detail = e.detail || {};
|
||
let folders = detail.folders || detail.folder || null;
|
||
if (!folders) return;
|
||
if (!Array.isArray(folders)) folders = [folders];
|
||
const sourceId = detail.sourceId ? String(detail.sourceId) : '';
|
||
const activeSourceId = getGlobalActiveSourceId();
|
||
|
||
folders.forEach(f => {
|
||
if (!f) return;
|
||
const key = folderCacheKey(f, sourceId);
|
||
_folderStatsCache.delete(key);
|
||
_folderStatsInflight.delete(key);
|
||
_folderPeekCache.delete(key);
|
||
clearFolderSummaryCache(f, sourceId);
|
||
|
||
if (!sourceId || sourceId === activeSourceId) {
|
||
try { refreshFolderIcon(f); } catch (e2) { /* ignore */ }
|
||
}
|
||
|
||
try {
|
||
const safe = CSS.escape(f);
|
||
const stripItem = document.querySelector(`#folderStripContainer .folder-item[data-folder="${safe}"]`);
|
||
if (stripItem) attachStripIconAsync(stripItem, f, 48, { sourceId });
|
||
document
|
||
.querySelectorAll(
|
||
`#fileList tr.folder-row[data-folder="${safe}"], ` +
|
||
`#fileListSecondary tr.folder-row[data-folder="${safe}"]`
|
||
)
|
||
.forEach(row => attachStripIconAsync(row, f, 28, { sourceId }));
|
||
} catch (e3) { /* ignore */ }
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Best-effort peek: first few direct child folders + files for a folder.
|
||
* Uses existing getFolderList.php + getFileList.php.
|
||
*
|
||
* Returns: { items: Array<{type,name}>, truncated: boolean }
|
||
*/
|
||
async function fetchFolderPeek(folder, sourceId = '') {
|
||
if (!folder) return null;
|
||
if (isSlowRemoteSource(sourceId)) return null;
|
||
|
||
const key = folderCacheKey(folder, sourceId);
|
||
if (_folderPeekCache.has(key)) {
|
||
return _folderPeekCache.get(key);
|
||
}
|
||
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
const p = (async () => {
|
||
try {
|
||
// 1) Files in this folder
|
||
let files = [];
|
||
try {
|
||
const res = await fetch(
|
||
withBase(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=0${sourceParam}&t=${Date.now()}`),
|
||
{ credentials: "include" }
|
||
);
|
||
const raw = await safeJson(res);
|
||
if (Array.isArray(raw.files)) {
|
||
files = raw.files;
|
||
} else if (raw.files && typeof raw.files === "object") {
|
||
files = Object.entries(raw.files).map(([name, meta]) => ({
|
||
...(meta || {}),
|
||
name
|
||
}));
|
||
}
|
||
} catch (e) {
|
||
// ignore file errors; we can still show folders
|
||
}
|
||
|
||
// 2) Direct subfolders
|
||
let subfolderNames = [];
|
||
try {
|
||
const res2 = await fetch(
|
||
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0${sourceParam}`),
|
||
{ credentials: "include" }
|
||
);
|
||
const raw2 = await safeJson(res2);
|
||
|
||
if (Array.isArray(raw2)) {
|
||
const allPaths = raw2.map(item => item.folder ?? item);
|
||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||
|
||
subfolderNames = allPaths
|
||
.filter(p => {
|
||
if (folder === "root") return p.indexOf("/") === -1;
|
||
if (!p.startsWith(folder + "/")) return false;
|
||
return p.split("/").length === depth;
|
||
})
|
||
.map(p => p.split("/").pop() || p);
|
||
}
|
||
} catch (e) {
|
||
// ignore folder errors
|
||
}
|
||
|
||
const items = [];
|
||
|
||
// Folders first
|
||
for (const name of subfolderNames) {
|
||
if (!name) continue;
|
||
items.push({ type: "folder", name });
|
||
if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
|
||
}
|
||
|
||
// Then a few files
|
||
if (items.length < FOLDER_PEEK_MAX_ITEMS && Array.isArray(files)) {
|
||
for (const f of files) {
|
||
if (!f || !f.name) continue;
|
||
items.push({ type: "file", name: f.name });
|
||
if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
|
||
}
|
||
}
|
||
|
||
// Were there more candidates than we showed?
|
||
const totalCandidates =
|
||
(Array.isArray(subfolderNames) ? subfolderNames.length : 0) +
|
||
(Array.isArray(files) ? files.length : 0);
|
||
|
||
const truncated = totalCandidates > items.length;
|
||
|
||
return { items, truncated };
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
})();
|
||
|
||
_folderPeekCache.set(key, p);
|
||
return p;
|
||
}
|
||
|
||
/* ===========================================================
|
||
SECURITY: build file URLs only via the API (no /uploads)
|
||
=========================================================== */
|
||
function apiFileUrl(folder, name, inline = false, sourceId = "") {
|
||
const fParam = folder && folder !== "root" ? folder : "root";
|
||
const q = new URLSearchParams({
|
||
folder: fParam,
|
||
file: name,
|
||
inline: inline ? "1" : "0"
|
||
});
|
||
let meta = null;
|
||
let sid = String(sourceId || "").trim();
|
||
|
||
try {
|
||
if (Array.isArray(fileData)) {
|
||
meta = fileData.find(
|
||
f => f.name === name && (f.folder || "root") === fParam
|
||
);
|
||
if (!sid && meta && meta.sourceId) {
|
||
sid = String(meta.sourceId || "").trim();
|
||
}
|
||
}
|
||
} catch (e) { /* best-effort only */ }
|
||
|
||
if (!sid) {
|
||
try {
|
||
sid = String(getActivePaneSourceId() || "").trim();
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
if (sid) q.set("sourceId", sid);
|
||
|
||
// Try to find this file in fileData to get a stable cache key
|
||
if (meta) {
|
||
const v = meta.cacheKey || meta.modified || meta.uploaded || meta.sizeBytes;
|
||
if (v != null && v !== "") {
|
||
q.set("t", String(v)); // stable per-file token
|
||
}
|
||
}
|
||
|
||
return withBase(`/api/file/download.php?${q.toString()}`);
|
||
}
|
||
|
||
function apiVideoThumbUrl(folder, name, sourceId = "") {
|
||
const fParam = folder && folder !== "root" ? folder : "root";
|
||
const q = new URLSearchParams({
|
||
folder: fParam,
|
||
file: name
|
||
});
|
||
if (sourceId) q.set("sourceId", sourceId);
|
||
|
||
// Try to find this file in fileData to get a stable cache key
|
||
try {
|
||
if (Array.isArray(fileData)) {
|
||
const meta = fileData.find(
|
||
f => f.name === name && (f.folder || "root") === fParam
|
||
);
|
||
if (meta) {
|
||
const v = meta.cacheKey || meta.modified || meta.uploaded || meta.sizeBytes;
|
||
if (v != null && v !== "") {
|
||
q.set("t", String(v)); // stable per-file token
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* best-effort only */ }
|
||
|
||
return withBase(`/api/file/thumbnail.php?${q.toString()}`);
|
||
}
|
||
|
||
// Wire "select all" header checkbox for the current table render
|
||
function wireSelectAll(fileListContent) {
|
||
// Be flexible about how the header checkbox is identified
|
||
const getSelectAll = () => fileListContent.querySelector(
|
||
'thead input[type="checkbox"].select-all, ' +
|
||
'thead .select-all input[type="checkbox"], ' +
|
||
'thead input#selectAll, ' +
|
||
'thead input#selectAllCheckbox, ' +
|
||
'thead input[data-select-all]'
|
||
);
|
||
const selectAll = getSelectAll();
|
||
if (!selectAll) return;
|
||
if (selectAll.__wiredSelectAll) return;
|
||
selectAll.__wiredSelectAll = true;
|
||
|
||
const getRowCbs = () =>
|
||
Array.from(fileListContent.querySelectorAll('tbody .file-checkbox'))
|
||
.filter(cb => !cb.disabled && !cb.closest('tr.folder-row'));
|
||
|
||
const clearFolderSelections = () => {
|
||
fileListContent.querySelectorAll('tbody .folder-checkbox:checked').forEach(cb => {
|
||
cb.checked = false;
|
||
updateRowHighlight(cb);
|
||
});
|
||
};
|
||
|
||
const applySelectAll = () => {
|
||
// Clear any folder selection when toggling all files
|
||
clearFolderSelections();
|
||
const checked = selectAll.checked;
|
||
getRowCbs().forEach(cb => {
|
||
cb.checked = checked;
|
||
updateRowHighlight(cb);
|
||
});
|
||
updateFileActionButtons();
|
||
// No indeterminate state when explicitly toggled
|
||
selectAll.indeterminate = false;
|
||
selectAll.__lastSelectAllChecked = selectAll.checked;
|
||
};
|
||
|
||
const handleSelectAllChange = () => {
|
||
applySelectAll();
|
||
};
|
||
|
||
// Toggle all rows when the header checkbox changes
|
||
selectAll.addEventListener('change', handleSelectAllChange);
|
||
|
||
// Some UI layers can swallow left-click toggles. Track state and force toggle if needed.
|
||
selectAll.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
selectAll.__lastSelectAllChecked = selectAll.checked;
|
||
});
|
||
|
||
selectAll.addEventListener('click', (e) => {
|
||
if (e.button !== 0) return;
|
||
if (selectAll.disabled) return;
|
||
const last = selectAll.__lastSelectAllChecked;
|
||
if (last === selectAll.checked) {
|
||
selectAll.checked = !selectAll.checked;
|
||
handleSelectAllChange();
|
||
}
|
||
selectAll.__lastSelectAllChecked = selectAll.checked;
|
||
});
|
||
|
||
const headerCell = selectAll.closest('th');
|
||
if (headerCell && !headerCell.__wiredSelectAllCell) {
|
||
headerCell.__wiredSelectAllCell = true;
|
||
headerCell.addEventListener('click', (e) => {
|
||
if (e.target === selectAll) return;
|
||
if (selectAll.disabled) return;
|
||
selectAll.checked = !selectAll.checked;
|
||
handleSelectAllChange();
|
||
});
|
||
}
|
||
|
||
// Keep header checkbox state in sync with row selections
|
||
const syncHeader = () => {
|
||
const master = getSelectAll();
|
||
if (!master) return;
|
||
const cbs = getRowCbs();
|
||
const total = cbs.length;
|
||
const checked = cbs.filter(cb => cb.checked).length;
|
||
if (!total) {
|
||
master.checked = false;
|
||
master.indeterminate = false;
|
||
return;
|
||
}
|
||
master.checked = checked === total;
|
||
master.indeterminate = checked > 0 && checked < total;
|
||
};
|
||
|
||
// Listen for any row checkbox changes to refresh header state
|
||
if (!fileListContent.__wiredSelectAllContainer) {
|
||
fileListContent.__wiredSelectAllContainer = true;
|
||
fileListContent.addEventListener('change', (e) => {
|
||
if (e.target && e.target.classList.contains('file-checkbox')) {
|
||
syncHeader();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Initial sync on mount
|
||
syncHeader();
|
||
}
|
||
|
||
function fillHoverPreviewForRow(row) {
|
||
if (isHoverPreviewDisabled()) {
|
||
hideHoverPreview();
|
||
return;
|
||
}
|
||
|
||
const el = ensureHoverPreviewEl();
|
||
const titleEl = el.querySelector(".hover-preview-title");
|
||
const metaEl = el.querySelector(".hover-preview-meta");
|
||
const thumbEl = el.querySelector(".hover-preview-thumb");
|
||
const propsEl = el.querySelector(".hover-preview-props");
|
||
const snippetEl = el.querySelector(".hover-preview-snippet");
|
||
|
||
|
||
|
||
if (!titleEl || !metaEl || !thumbEl || !propsEl || !snippetEl) return;
|
||
|
||
// Reset content
|
||
thumbEl.innerHTML = "";
|
||
propsEl.innerHTML = "";
|
||
snippetEl.textContent = "";
|
||
snippetEl.style.display = "none";
|
||
metaEl.textContent = "";
|
||
titleEl.textContent = "";
|
||
|
||
// reset snippet style defaults (for file previews)
|
||
snippetEl.style.whiteSpace = "pre-wrap";
|
||
snippetEl.style.overflowX = "auto";
|
||
snippetEl.style.textOverflow = "clip";
|
||
snippetEl.style.wordBreak = "break-word";
|
||
|
||
// Reset per-row sizing...
|
||
thumbEl.style.minHeight = "0";
|
||
|
||
const isFolder = row.classList.contains("folder-row");
|
||
|
||
if (isFolder) {
|
||
// =========================
|
||
// FOLDER HOVER PREVIEW
|
||
// =========================
|
||
const folderPath = row.dataset.folder || "";
|
||
const folderName = folderPath.split("/").pop() || folderPath || "(root)";
|
||
|
||
titleEl.textContent = folderName;
|
||
|
||
hoverPreviewContext = {
|
||
type: "folder",
|
||
folder: folderPath
|
||
};
|
||
|
||
// Right column: icon + path (start props array so we can append later)
|
||
const props = [];
|
||
|
||
props.push(`
|
||
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
|
||
<strong>${t("folder") || "Folder"}</strong>
|
||
</div>
|
||
`);
|
||
|
||
props.push(`
|
||
<div class="hover-prop-line">
|
||
<strong>${t("path") || "Path"}:</strong> ${escapeHTML(folderPath || "root")}
|
||
</div>
|
||
`);
|
||
|
||
propsEl.innerHTML = props.join("");
|
||
|
||
// --- Owner + "Your access" (from capabilities) --------------------
|
||
fetchFolderCaps(folderPath, getActivePaneSourceId()).then(caps => {
|
||
if (!caps || !document.body.contains(el)) return;
|
||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||
|
||
// Update the type label to include "(encrypted)" when applicable
|
||
const isEnc = !!(caps && caps.encryption && caps.encryption.encrypted);
|
||
props[0] = `
|
||
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
|
||
<strong>${t("folder") || "Folder"}${isEnc ? " (encrypted)" : ""}</strong>
|
||
</div>
|
||
`;
|
||
|
||
const owner = caps.owner || caps.user || "";
|
||
if (owner) {
|
||
props.push(`
|
||
<div class="hover-prop-line">
|
||
<strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(owner)}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// Summarize what the current user can do in this folder
|
||
const perms = [];
|
||
if (caps.canUpload || caps.canCreate) perms.push(t("perm_upload") || "Upload");
|
||
if (caps.canMoveFolder) perms.push(t("perm_move") || "Move");
|
||
if (caps.canRename) perms.push(t("perm_rename") || "Rename");
|
||
if (caps.canShareFolder) perms.push(t("perm_share") || "Share");
|
||
if (caps.canDeleteFolder || caps.canDelete)
|
||
perms.push(t("perm_delete") || "Delete");
|
||
|
||
if (perms.length) {
|
||
const label = t("your_access") || "Your access";
|
||
props.push(`
|
||
<div class="hover-prop-line">
|
||
<strong>${escapeHTML(label)}:</strong> ${escapeHTML(perms.join(", "))}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
propsEl.innerHTML = props.join("");
|
||
}).catch(() => {});
|
||
// ------------------------------------------------------------------
|
||
|
||
// --- Meta: counts + size + created/modified -----------------------
|
||
fetchFolderStats(folderPath, getActivePaneSourceId()).then(stats => {
|
||
if (!stats || !document.body.contains(el)) return;
|
||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||
|
||
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
||
|
||
let bytes = null;
|
||
const sizeCandidates = [stats.bytes, stats.sizeBytes, stats.size, stats.totalBytes];
|
||
for (const v of sizeCandidates) {
|
||
const n = Number(v);
|
||
if (Number.isFinite(n) && n >= 0) {
|
||
bytes = n;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const pieces = [];
|
||
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
||
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
|
||
if (!pieces.length) pieces.push("0 items");
|
||
|
||
const sizeLabel = bytes != null && bytes >= 0 ? formatSize(bytes) : "";
|
||
metaEl.textContent = sizeLabel
|
||
? `${pieces.join(", ")} • ${sizeLabel}`
|
||
: pieces.join(", ");
|
||
|
||
// Optional: created / modified range under the path/owner/access
|
||
const created = typeof stats.earliest_uploaded === "string" ? stats.earliest_uploaded : "";
|
||
const modified = typeof stats.latest_mtime === "string" ? stats.latest_mtime : "";
|
||
|
||
if (modified) {
|
||
props.push(`
|
||
<div class="hover-prop-line">
|
||
<strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(modified)}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
if (created) {
|
||
props.push(`
|
||
<div class="hover-prop-line">
|
||
<strong>${t("created") || "Created"}:</strong> ${escapeHTML(created)}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
propsEl.innerHTML = props.join("");
|
||
}).catch(() => {});
|
||
// ------------------------------------------------------------------
|
||
|
||
// Left side: peek inside folder (first few children)
|
||
fetchFolderPeek(folderPath, getActivePaneSourceId()).then(result => {
|
||
if (!document.body.contains(el)) return;
|
||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||
|
||
// Folder mode: force single-line-ish behavior and avoid wrapping
|
||
snippetEl.style.whiteSpace = "pre";
|
||
snippetEl.style.wordBreak = "normal";
|
||
snippetEl.style.overflowX = "hidden";
|
||
snippetEl.style.textOverflow = "ellipsis";
|
||
|
||
if (!result) {
|
||
const msg =
|
||
t("no_files_or_folders") ||
|
||
t("no_files_found") ||
|
||
"No files or folders";
|
||
|
||
snippetEl.textContent = msg;
|
||
snippetEl.style.display = "block";
|
||
return;
|
||
}
|
||
|
||
const { items, truncated } = result;
|
||
|
||
if (!items || !items.length) {
|
||
const msg =
|
||
t("no_files_or_folders") ||
|
||
t("no_files_found") ||
|
||
"No files or folders";
|
||
|
||
snippetEl.textContent = msg;
|
||
snippetEl.style.display = "block";
|
||
return;
|
||
}
|
||
|
||
const MAX_LABEL_CHARS = 42; // tweak to taste
|
||
|
||
const lines = items.map(it => {
|
||
const prefix = it.type === "folder" ? "📁 " : "📄 ";
|
||
const trimmed = _trimLabel(it.name, MAX_LABEL_CHARS);
|
||
return prefix + trimmed;
|
||
});
|
||
|
||
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, show a clean final "…"
|
||
if (truncated && lines.length) {
|
||
lines[lines.length - 1] = "…";
|
||
}
|
||
|
||
snippetEl.textContent = lines.join("\n");
|
||
snippetEl.style.display = "block";
|
||
}).catch(() => {});
|
||
|
||
} else {
|
||
// ======================
|
||
// FILE HOVER PREVIEW
|
||
// ======================
|
||
const name = row.getAttribute("data-file-name");
|
||
|
||
// If this row isn't a real file row (e.g. "No files found"), don't show hover preview.
|
||
if (!name) {
|
||
hoverPreviewContext = null;
|
||
hideHoverPreview();
|
||
return;
|
||
}
|
||
|
||
const paneEl = row.closest('.file-list-pane');
|
||
const paneKey = paneEl && paneEl.classList.contains('secondary-pane') ? 'secondary' : 'primary';
|
||
const paneFiles = (window.__frPaneState &&
|
||
window.__frPaneState[paneKey] &&
|
||
Array.isArray(window.__frPaneState[paneKey].fileData))
|
||
? window.__frPaneState[paneKey].fileData
|
||
: (Array.isArray(fileData) ? fileData : []);
|
||
const rawName = decodeHtmlEntities(name);
|
||
const file = paneFiles.find(f => f.name === rawName || escapeHTML(f.name) === name);
|
||
|
||
// If we can't resolve a real file from fileData, also skip the preview
|
||
if (!file) {
|
||
hoverPreviewContext = null;
|
||
hideHoverPreview();
|
||
return;
|
||
}
|
||
|
||
hoverPreviewContext = {
|
||
type: "file",
|
||
file
|
||
};
|
||
|
||
titleEl.textContent = file.name;
|
||
|
||
// IMPORTANT: no duplicate "size • modified • owner" under the title
|
||
metaEl.textContent = "";
|
||
|
||
const ext = getFileExt(file.name);
|
||
const lower = file.name.toLowerCase();
|
||
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|heic)$/i.test(lower);
|
||
const isVideo = /\.(mp4|mkv|webm|mov|ogv)$/i.test(lower);
|
||
const isAudio = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(lower);
|
||
const isPdf = /\.pdf$/i.test(lower);
|
||
|
||
const folder = file.folder || window.currentFolder || "root";
|
||
const sourceId = String(
|
||
file.sourceId ||
|
||
(window.__frPaneState && window.__frPaneState[paneKey] && window.__frPaneState[paneKey].sourceId) ||
|
||
getActivePaneSourceId() ||
|
||
""
|
||
).trim();
|
||
const url = apiFileUrl(folder, file.name, true, sourceId);
|
||
const canTextPreview = canEditFile(file.name);
|
||
const isOfficeSnippet = OFFICE_SNIPPET_EXTS.has(ext);
|
||
|
||
// Right column: file details (always render, even if preview errors)
|
||
let iconName = "insert_drive_file";
|
||
if (isImage) iconName = "image";
|
||
else if (isVideo) iconName = "movie";
|
||
else if (isAudio) iconName = "audiotrack";
|
||
else if (isPdf) iconName = "picture_as_pdf";
|
||
|
||
const props = [];
|
||
|
||
// Icon row at the top of the right column
|
||
props.push(`
|
||
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
|
||
<span class="hover-preview-icon material-icons" style="margin-right:6px;">${iconName}</span>
|
||
<strong>${escapeHTML(ext || "").toUpperCase() || t("file") || "File"}</strong>
|
||
</div>
|
||
`);
|
||
|
||
if (ext) {
|
||
props.push(`<div class="hover-prop-line"><strong>${t("extension") || "Ext"}:</strong> .${escapeHTML(ext)}</div>`);
|
||
}
|
||
if (Number.isFinite(file.sizeBytes) && file.sizeBytes >= 0) {
|
||
const prettySize = formatSize(file.sizeBytes);
|
||
props.push(`
|
||
<div class="hover-prop-line hover-prop-size">
|
||
<strong>${t("size") || "Size"}:</strong>
|
||
<span class="hover-prop-value"
|
||
style="margin-left:4px; font-variant-numeric:tabular-nums;">
|
||
${escapeHTML(prettySize)}
|
||
</span>
|
||
</div>
|
||
`);
|
||
}
|
||
if (file.modified) {
|
||
props.push(`<div class="hover-prop-line"><strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(file.modified)}</div>`);
|
||
}
|
||
if (file.uploaded) {
|
||
props.push(`<div class="hover-prop-line"><strong>${t("created") || "Created"}:</strong> ${escapeHTML(file.uploaded)}</div>`);
|
||
}
|
||
if (file.uploader) {
|
||
props.push(`<div class="hover-prop-line"><strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(file.uploader)}</div>`);
|
||
}
|
||
|
||
// --- NEW: Tags / Metadata line ------------------------------------
|
||
(function addMetaLine() {
|
||
// Tags from backend: file.tags = [{ name, color }, ...]
|
||
const tagNames = Array.isArray(file.tags)
|
||
? file.tags
|
||
.map(t => t && t.name ? String(t.name).trim() : "")
|
||
.filter(Boolean)
|
||
: [];
|
||
|
||
// Optional extra metadata if you ever add it to fileData
|
||
const mime =
|
||
file.mime ||
|
||
file.mimetype ||
|
||
file.contentType ||
|
||
"";
|
||
|
||
const extraPieces = [];
|
||
if (mime) extraPieces.push(mime);
|
||
|
||
// Example future fields; safe even if undefined
|
||
if (Number.isFinite(file.durationSeconds)) {
|
||
extraPieces.push(`${file.durationSeconds}s`);
|
||
}
|
||
if (file.width && file.height) {
|
||
extraPieces.push(`${file.width}×${file.height}`);
|
||
}
|
||
|
||
const parts = [];
|
||
|
||
if (tagNames.length) {
|
||
parts.push(tagNames.join(", "));
|
||
}
|
||
if (extraPieces.length) {
|
||
parts.push(extraPieces.join(" • "));
|
||
}
|
||
|
||
if (!parts.length) return; // nothing to show
|
||
|
||
const useMetadataLabel = parts.length > 1 || extraPieces.length > 0;
|
||
const labelKey = useMetadataLabel ? "metadata" : "tags";
|
||
const label = t(labelKey) || (useMetadataLabel ? "MetaData" : "Tags");
|
||
|
||
props.push(
|
||
`<div class="hover-prop-line"><strong>${escapeHTML(label)}:</strong> ${escapeHTML(parts.join(" • "))}</div>`
|
||
);
|
||
})();
|
||
// ------------------------------------------------------------------
|
||
|
||
propsEl.innerHTML = props.join("");
|
||
|
||
// Left: image / video preview OR text snippet OR "No preview"
|
||
if (isImage) {
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
const maxImagePreviewBytes = getMaxImagePreviewBytes();
|
||
if (bytes != null && bytes > maxImagePreviewBytes) {
|
||
thumbEl.innerHTML = `
|
||
<div style="
|
||
padding:6px 8px;
|
||
border-radius:6px;
|
||
font-size:0.82rem;
|
||
text-align:center;
|
||
background-color:rgba(15,23,42,0.92);
|
||
color:#e5e7eb;
|
||
max-width:100%;
|
||
">
|
||
${escapeHTML(t("preview_too_large") || "Preview disabled for large image")}
|
||
</div>
|
||
`;
|
||
} else {
|
||
// --- image thumbnail
|
||
thumbEl.style.minHeight = "140px";
|
||
const img = document.createElement("img");
|
||
img.src = url;
|
||
img.alt = file.name;
|
||
img.style.maxWidth = "180px";
|
||
img.style.maxHeight = "120px";
|
||
img.style.display = "block";
|
||
thumbEl.appendChild(img);
|
||
}
|
||
|
||
} else if (isVideo) {
|
||
// --- video thumbnail (ffmpeg-backed) ---
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
const maxVideoPreviewBytes = getMaxVideoPreviewBytes();
|
||
const fallbackMsg = t("no_preview_available") || "No preview available";
|
||
const isStillActive = () =>
|
||
hoverPreviewContext &&
|
||
hoverPreviewContext.type === "file" &&
|
||
hoverPreviewContext.file === file;
|
||
const renderVideoFallbackMessage = () => {
|
||
if (!isStillActive()) return;
|
||
thumbEl.innerHTML = `
|
||
<div style="
|
||
padding:6px 8px;
|
||
border-radius:6px;
|
||
font-size:0.8rem;
|
||
text-align:center;
|
||
background-color:rgba(15,23,42,0.92);
|
||
color:#e5e7eb;
|
||
max-width:100%;
|
||
">
|
||
${escapeHTML(fallbackMsg)}
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const sourceType = String(getSourceTypeById(sourceId || getActivePaneSourceId()) || '').toLowerCase();
|
||
const isRemoteSource = sourceId !== '' && sourceType !== 'local';
|
||
const canStreamRemotePreview = isRemoteSource
|
||
&& !isFtpSource(sourceId)
|
||
&& bytes != null
|
||
&& bytes <= maxVideoPreviewBytes;
|
||
|
||
const renderRemoteVideoPreview = () => {
|
||
if (!isStillActive()) return;
|
||
|
||
const previewUrl = apiFileUrl(folder, file.name, true, sourceId);
|
||
thumbEl.innerHTML = "";
|
||
thumbEl.style.minHeight = "140px";
|
||
thumbEl.style.position = "relative";
|
||
|
||
const video = document.createElement("video");
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.preload = "metadata";
|
||
video.src = previewUrl;
|
||
video.style.maxWidth = "200px";
|
||
video.style.maxHeight = "120px";
|
||
video.style.display = "block";
|
||
video.style.borderRadius = "6px";
|
||
video.style.background = "rgba(0,0,0,0.15)";
|
||
video.setAttribute("aria-label", file.name);
|
||
|
||
video.addEventListener("loadedmetadata", () => {
|
||
try {
|
||
video.currentTime = 0.1;
|
||
} catch (e) { /* ignore */ }
|
||
}, { once: true });
|
||
|
||
video.addEventListener("error", () => {
|
||
renderVideoFallbackMessage();
|
||
}, { once: true });
|
||
|
||
thumbEl.appendChild(video);
|
||
|
||
const overlay = document.createElement("span");
|
||
overlay.className = "material-icons";
|
||
overlay.textContent = "play_circle";
|
||
overlay.style.position = "absolute";
|
||
overlay.style.left = "50%";
|
||
overlay.style.top = "50%";
|
||
overlay.style.transform = "translate(-50%, -50%)";
|
||
overlay.style.fontSize = "1.6rem";
|
||
overlay.style.opacity = "0.85";
|
||
overlay.style.color = "rgba(255,255,255,0.9)";
|
||
overlay.style.textShadow = "0 2px 6px rgba(0,0,0,0.5)";
|
||
overlay.style.pointerEvents = "none";
|
||
thumbEl.appendChild(overlay);
|
||
};
|
||
|
||
if (isRemoteSource) {
|
||
if (canStreamRemotePreview) {
|
||
renderRemoteVideoPreview();
|
||
} else {
|
||
renderVideoFallbackMessage();
|
||
}
|
||
} else {
|
||
const thumbUrl = apiVideoThumbUrl(folder, file.name, sourceId);
|
||
|
||
if (bytes == null || bytes <= maxVideoPreviewBytes) {
|
||
thumbEl.style.minHeight = "140px";
|
||
thumbEl.style.position = "relative";
|
||
|
||
const img = document.createElement("img");
|
||
img.src = thumbUrl;
|
||
img.alt = file.name;
|
||
img.style.maxWidth = "200px";
|
||
img.style.maxHeight = "120px";
|
||
img.style.display = "block";
|
||
img.style.borderRadius = "6px";
|
||
|
||
img.addEventListener("error", () => {
|
||
renderVideoFallbackMessage();
|
||
});
|
||
|
||
thumbEl.appendChild(img);
|
||
|
||
const overlay = document.createElement("span");
|
||
overlay.className = "material-icons";
|
||
overlay.textContent = "play_circle";
|
||
overlay.style.position = "absolute";
|
||
overlay.style.left = "50%";
|
||
overlay.style.top = "50%";
|
||
overlay.style.transform = "translate(-50%, -50%)";
|
||
overlay.style.fontSize = "1.6rem";
|
||
overlay.style.opacity = "0.85";
|
||
overlay.style.color = "rgba(255,255,255,0.9)";
|
||
overlay.style.textShadow = "0 2px 6px rgba(0,0,0,0.5)";
|
||
overlay.style.pointerEvents = "none";
|
||
thumbEl.appendChild(overlay);
|
||
} else {
|
||
renderVideoFallbackMessage();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Text snippet (left) for smaller text/code files
|
||
if (canTextPreview || isOfficeSnippet) {
|
||
fillFileSnippet(file, snippetEl);
|
||
} else if (!isImage && !isVideo) {
|
||
// Non-image, non-video, non-text → explicit "No preview"
|
||
const msg = t("no_preview_available") || "No preview available";
|
||
thumbEl.innerHTML = `
|
||
<div style="
|
||
padding:6px 8px;
|
||
border-radius:6px;
|
||
font-size:0.8rem;
|
||
text-align:center;
|
||
background-color:rgba(15,23,42,0.92);
|
||
color:#e5e7eb;
|
||
max-width:100%;
|
||
">
|
||
${escapeHTML(msg)}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
function positionHoverPreview(x, y) {
|
||
const el = ensureHoverPreviewEl();
|
||
const CARD_OFFSET_X = 16;
|
||
const CARD_OFFSET_Y = 12;
|
||
|
||
let left = x + CARD_OFFSET_X;
|
||
let top = y + CARD_OFFSET_Y;
|
||
|
||
const rect = el.getBoundingClientRect();
|
||
const vw = window.innerWidth;
|
||
const vh = window.innerHeight;
|
||
|
||
if (left + rect.width > vw - 10) {
|
||
left = x - rect.width - CARD_OFFSET_X;
|
||
}
|
||
if (top + rect.height > vh - 10) {
|
||
top = y - rect.height - CARD_OFFSET_Y;
|
||
}
|
||
|
||
el.style.left = `${Math.max(4, left)}px`;
|
||
el.style.top = `${Math.max(4, top)}px`;
|
||
}
|
||
// ---- Folder-strip icon helpers (same geometry as tree, but colored inline) ----
|
||
function _hexToHsl(hex) {
|
||
hex = String(hex || '').replace('#', '');
|
||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||
const b = parseInt(hex.slice(4, 6), 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 > .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;
|
||
default: h = (r - g) / d + 4;
|
||
}
|
||
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)); }
|
||
|
||
let _folderIconRenderSeq = 0;
|
||
|
||
function _isHexColor(value) {
|
||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim());
|
||
}
|
||
|
||
function sanitizeTagColor(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return '#777777';
|
||
if (_isHexColor(raw)) return raw;
|
||
if (/^[a-zA-Z]{1,32}$/.test(raw)) return raw;
|
||
return '#777777';
|
||
}
|
||
|
||
function _resolveFolderColors(hostEl, fullPath) {
|
||
const frontVar = String(hostEl?.style?.getPropertyValue('--filr-folder-front') || '').trim();
|
||
const backVar = String(hostEl?.style?.getPropertyValue('--filr-folder-back') || '').trim();
|
||
const strokeVar = String(hostEl?.style?.getPropertyValue('--filr-folder-stroke') || '').trim();
|
||
const baseHex = _isHexColor(frontVar)
|
||
? frontVar
|
||
: ((window.folderColorMap && window.folderColorMap[fullPath]) || '#f6b84e');
|
||
|
||
return {
|
||
front: _isHexColor(frontVar) ? frontVar : baseHex,
|
||
back: _isHexColor(backVar) ? backVar : _lighten(baseHex, 14),
|
||
stroke: _isHexColor(strokeVar) ? strokeVar : _darken(baseHex, 22)
|
||
};
|
||
}
|
||
|
||
function _applyFolderColors(hostEl, fullPath) {
|
||
if (!hostEl) return;
|
||
const { front, back, stroke } = _resolveFolderColors(hostEl, fullPath);
|
||
hostEl.style.setProperty('--filr-folder-front', front);
|
||
hostEl.style.setProperty('--filr-folder-back', back);
|
||
hostEl.style.setProperty('--filr-folder-stroke', stroke);
|
||
}
|
||
|
||
function _stampFolderIcon(iconSpan) {
|
||
if (!iconSpan) return '';
|
||
const seq = String(++_folderIconRenderSeq);
|
||
iconSpan.dataset.renderSeq = seq;
|
||
return seq;
|
||
}
|
||
|
||
// tiny fetch helper with timeout for folder counts
|
||
function _fetchJSONWithTimeout(url, ms = 2500) {
|
||
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, __fr_err: 1 })
|
||
.catch(() => ({ folders: 0, files: 0, __fr_err: 1 }))
|
||
.finally(() => clearTimeout(tid));
|
||
}
|
||
|
||
// Defensive: some browsers occasionally blank out inline SVGs elsewhere when a context menu opens.
|
||
// This only repaints *blank* strip/inline-folder icons (no re-render churn when nothing is wrong).
|
||
export function repairBlankFolderIcons({ force = false, skipStats = false } = {}) {
|
||
const isBroken = (el) => {
|
||
if (!el) return true;
|
||
const html = String(el.innerHTML || '').trim();
|
||
if (!html) return true;
|
||
const svg = el.querySelector('svg');
|
||
if (!svg) return true;
|
||
// If the SVG exists but is missing expected parts, treat it as broken.
|
||
// (Some Safari/WebKit glitches leave an empty <svg> shell.)
|
||
if (!svg.querySelector('.folder-front') && !svg.querySelector('.folder-back')) return true;
|
||
const rect = svg.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return true;
|
||
return false;
|
||
};
|
||
|
||
// Folder strip icons
|
||
try {
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) {
|
||
const sourceId = getActivePaneSourceId();
|
||
strip.querySelectorAll('.folder-item[data-folder]').forEach(item => {
|
||
const folder = item.getAttribute('data-folder') || '';
|
||
if (!folder) return;
|
||
const iconSpan = item.querySelector('.folder-svg');
|
||
if (!force && !isBroken(iconSpan)) return;
|
||
attachStripIconAsync(item, folder, 48, { preserveKind: true, sourceId, skipStats });
|
||
});
|
||
}
|
||
} catch (e) { /* best effort */ }
|
||
|
||
// Inline folder rows
|
||
try {
|
||
document.querySelectorAll('#fileList tr.folder-row[data-folder], #fileListSecondary tr.folder-row[data-folder]').forEach(row => {
|
||
const folder = row.getAttribute('data-folder') || '';
|
||
if (!folder) return;
|
||
const iconSpan = row.querySelector('.folder-svg');
|
||
if (!force && !isBroken(iconSpan)) return;
|
||
const sourceId = getPaneSourceIdForElement(row);
|
||
attachStripIconAsync(row, folder, 28, { preserveKind: true, sourceId, skipStats });
|
||
});
|
||
} catch (e) { /* best effort */ }
|
||
}
|
||
|
||
function forceRepaintInlineFolderIcons(listEl) {
|
||
if (!listEl) return;
|
||
try {
|
||
listEl.querySelectorAll('tr.folder-row[data-folder]').forEach(row => {
|
||
const folder = row.getAttribute('data-folder') || '';
|
||
if (!folder) return;
|
||
const iconSpan = row.querySelector('.folder-svg');
|
||
if (!iconSpan) return;
|
||
_stampFolderIcon(iconSpan);
|
||
_applyFolderColors(row, folder);
|
||
const kind = iconSpan.dataset.kind || 'empty';
|
||
iconSpan.dataset.kind = kind;
|
||
iconSpan.innerHTML = folderSVG(kind, { encrypted: isEncryptedForFolderIcon(folder) });
|
||
});
|
||
syncFolderIconSizeToRowHeight();
|
||
} catch (e) { /* best effort */ }
|
||
}
|
||
|
||
function scheduleFolderIconRepair(opts = {}) {
|
||
try {
|
||
const { force = false, skipStats = false } = opts || {};
|
||
const kick = () => { try { repairBlankFolderIcons({ force, skipStats }); } catch (e) {} };
|
||
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
|
||
setTimeout(kick, 80);
|
||
setTimeout(kick, 250);
|
||
if (force) {
|
||
setTimeout(kick, 800);
|
||
const gentle = () => { try { repairBlankFolderIcons({ force: false, skipStats: true }); } catch (e) {} };
|
||
setTimeout(gentle, 600);
|
||
setTimeout(gentle, 1200);
|
||
setTimeout(gentle, 2000);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function scheduleInactivePaneFolderIconRepair() {
|
||
if (!window.dualPaneEnabled) return;
|
||
try {
|
||
const kick = () => {
|
||
try {
|
||
const list = document.getElementById('fileListSecondary');
|
||
forceRepaintInlineFolderIcons(list);
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
if (typeof queueMicrotask === 'function') queueMicrotask(kick);
|
||
setTimeout(kick, 80);
|
||
setTimeout(kick, 250);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// Paint initial icon, then flip to "paper" if non-empty
|
||
function attachStripIconAsync(hostEl, fullPath, size = 28, { preserveKind = false, sourceId = '', skipStats = false } = {}) {
|
||
_applyFolderColors(hostEl, fullPath);
|
||
|
||
const iconSpan = hostEl.querySelector('.folder-svg');
|
||
if (!iconSpan) return;
|
||
const renderSeq = _stampFolderIcon(iconSpan);
|
||
|
||
const encrypted = isEncryptedForFolderIcon(fullPath);
|
||
|
||
// 1) initial icon
|
||
const currentKind = iconSpan.dataset.kind || 'empty';
|
||
const firstKind = preserveKind ? currentKind : 'empty';
|
||
iconSpan.dataset.kind = firstKind;
|
||
iconSpan.innerHTML = folderSVG(firstKind, { encrypted });
|
||
|
||
// make sure this brand-new SVG is sized correctly
|
||
try { syncFolderIconSizeToRowHeight(); } catch (e) {}
|
||
|
||
if (skipStats) return;
|
||
|
||
const resolvedSource = sourceId || getActivePaneSourceId();
|
||
fetchFolderStats(fullPath, resolvedSource)
|
||
.then(stats => {
|
||
if (!iconSpan.isConnected) return;
|
||
if (iconSpan.dataset.renderSeq && iconSpan.dataset.renderSeq !== renderSeq) return;
|
||
if (!stats) return;
|
||
const folders = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||
const files = Number.isFinite(stats.files) ? stats.files : 0;
|
||
|
||
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
||
iconSpan.dataset.kind = 'paper';
|
||
iconSpan.innerHTML = folderSVG('paper', { encrypted });
|
||
try { syncFolderIconSizeToRowHeight(); } catch (e) {}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
/* -----------------------------
|
||
Helper: robust JSON handling
|
||
----------------------------- */
|
||
// Parse JSON if possible; throw on non-2xx with useful message & status
|
||
async function safeJson(res) {
|
||
const text = await res.text();
|
||
let body = null;
|
||
try { body = text ? JSON.parse(text) : null; } catch (e) { /* 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 normalizeFolderPath(folder) {
|
||
if (!folder) return "";
|
||
const trimmed = String(folder).trim().replace(/^\/+|\/+$/g, "");
|
||
return trimmed === "" ? "root" : trimmed;
|
||
}
|
||
|
||
function folderDepthScore(folder) {
|
||
if (!folder || folder === "root") return 0;
|
||
return folder.split("/").filter(Boolean).length;
|
||
}
|
||
|
||
async function findBestAccessibleFolder({ lastOpenedFolder, sourceId = '' } = {}) {
|
||
try {
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
const res = await fetch(withBase(`/api/folder/getFolderList.php?counts=0${sourceParam}`), { credentials: 'include' });
|
||
const data = await safeJson(res);
|
||
const names = Array.isArray(data)
|
||
? data.map(row => normalizeFolderPath(row?.folder || row)).filter(Boolean)
|
||
: [];
|
||
if (!names.length) return null;
|
||
|
||
const unique = Array.from(new Set(names));
|
||
const preferred = normalizeFolderPath(lastOpenedFolder);
|
||
if (preferred && unique.includes(preferred)) return preferred;
|
||
|
||
unique.sort((a, b) => {
|
||
const depthDiff = folderDepthScore(a) - folderDepthScore(b);
|
||
return depthDiff !== 0 ? depthDiff : a.localeCompare(b);
|
||
});
|
||
return unique[0] || null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// --- Folder capabilities + owner cache ----------------------
|
||
const _folderCapsCache = window.__FR_CAPS_CACHE || new Map();
|
||
const _folderCapsInflight = window.__FR_CAPS_INFLIGHT || new Map();
|
||
window.__FR_CAPS_CACHE = _folderCapsCache;
|
||
window.__FR_CAPS_INFLIGHT = _folderCapsInflight;
|
||
|
||
async function fetchFolderCaps(folder, sourceId = '') {
|
||
if (!folder) return null;
|
||
const key = folderCacheKey(folder, sourceId);
|
||
if (_folderCapsCache.has(key)) {
|
||
return _folderCapsCache.get(key);
|
||
}
|
||
if (_folderCapsInflight.has(key)) {
|
||
return _folderCapsInflight.get(key);
|
||
}
|
||
|
||
const p = (async () => {
|
||
try {
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
const res = await fetch(
|
||
withBase(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}${sourceParam}`),
|
||
{ credentials: 'include' }
|
||
);
|
||
const data = await safeJson(res);
|
||
_folderCapsCache.set(key, data || null);
|
||
|
||
if (data && (data.owner || data.user)) {
|
||
_folderOwnerCache.set(key, data.owner || data.user || "");
|
||
}
|
||
return data || null;
|
||
} catch (e) {
|
||
_folderCapsCache.set(key, null);
|
||
return null;
|
||
} finally {
|
||
_folderCapsInflight.delete(key);
|
||
}
|
||
})();
|
||
|
||
_folderCapsInflight.set(key, p);
|
||
return p;
|
||
}
|
||
|
||
async function refreshCurrentFolderCaps(folder, sourceId = '', paneKey = '') {
|
||
const pane = paneKey ? normalizePaneKey(paneKey) : normalizePaneKey(window.activePane);
|
||
const resolvedSource = sourceId || getPaneSourceId(pane);
|
||
window.currentFolderCaps = null;
|
||
savePaneState(pane, { currentFolderCaps: null });
|
||
try {
|
||
const caps = await fetchFolderCaps(folder, resolvedSource);
|
||
window.currentFolderCaps = caps || null;
|
||
savePaneState(pane, { currentFolderCaps: window.currentFolderCaps });
|
||
} catch (e) {
|
||
window.currentFolderCaps = null;
|
||
savePaneState(pane, { currentFolderCaps: null });
|
||
}
|
||
updateEncryptedFolderBanner(folder);
|
||
refreshEncryptedFolderIconsInList();
|
||
applyEncryptedFolderUiRestrictions();
|
||
updateFileActionButtons();
|
||
}
|
||
|
||
// --- Folder owner cache + helper ----------------------
|
||
const _folderOwnerCache = new Map();
|
||
|
||
async function fetchFolderOwner(folder, sourceId = '') {
|
||
if (!folder) return "";
|
||
const key = folderCacheKey(folder, sourceId);
|
||
if (_folderOwnerCache.has(key)) {
|
||
return _folderOwnerCache.get(key);
|
||
}
|
||
|
||
try {
|
||
const data = await fetchFolderCaps(folder, sourceId);
|
||
const owner = data && (data.owner || data.user || "");
|
||
_folderOwnerCache.set(key, owner || "");
|
||
return owner || "";
|
||
} catch (e) {
|
||
_folderOwnerCache.set(key, "");
|
||
return "";
|
||
}
|
||
}
|
||
// ---- Viewed badges (table + gallery) ----
|
||
// ---------- Badge factory (center text vertically) ----------
|
||
function makeBadge(state) {
|
||
if (!state) return null;
|
||
const el = document.createElement('span');
|
||
el.className = 'status-badge';
|
||
el.style.cssText = [
|
||
'display:inline-flex',
|
||
'align-items:center',
|
||
'justify-content:center',
|
||
'vertical-align:middle',
|
||
'margin-left:6px',
|
||
'padding:2px 8px',
|
||
'min-height:18px',
|
||
'line-height:1',
|
||
'border-radius:999px',
|
||
'font-size:.78em',
|
||
'border:1px solid rgba(0,0,0,.2)',
|
||
'background:rgba(0,0,0,.06)'
|
||
].join(';');
|
||
|
||
if (state.completed) {
|
||
el.classList.add('watched');
|
||
el.textContent = (t('watched') || t('viewed') || 'Watched');
|
||
el.style.borderColor = 'rgba(34,197,94,.45)';
|
||
el.style.background = 'rgba(34,197,94,.15)';
|
||
el.style.color = '#22c55e';
|
||
return el;
|
||
}
|
||
|
||
if (Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||
el.classList.add('progress');
|
||
el.textContent = `${pct}%`;
|
||
el.style.borderColor = 'rgba(234,88,12,.55)';
|
||
el.style.background = 'rgba(234,88,12,.18)';
|
||
el.style.color = '#ea580c';
|
||
return el;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ---------- Public: set/clear badges for one file (table + gallery) ----------
|
||
function applyBadgeToDom(name, state) {
|
||
const safe = CSS.escape(name);
|
||
|
||
// Table
|
||
document.querySelectorAll(`tr[data-file-name="${safe}"] .name-cell, tr[data-file-name="${safe}"] .file-name-cell`)
|
||
.forEach(cell => {
|
||
cell.querySelector('.status-badge')?.remove();
|
||
const b = makeBadge(state);
|
||
if (b) cell.appendChild(b);
|
||
});
|
||
|
||
// Gallery
|
||
document.querySelectorAll(`.gallery-card[data-file-name="${safe}"] .gallery-file-name`)
|
||
.forEach(title => {
|
||
title.querySelector('.status-badge')?.remove();
|
||
const b = makeBadge(state);
|
||
if (b) title.appendChild(b);
|
||
});
|
||
}
|
||
|
||
export function setFileWatchedBadge(name, watched = true) {
|
||
applyBadgeToDom(name, watched ? { completed: true } : null);
|
||
}
|
||
|
||
export function setFileProgressBadge(name, seconds, duration) {
|
||
if (duration > 0 && seconds >= 0) {
|
||
applyBadgeToDom(name, { seconds, duration, completed: seconds >= duration - 1 });
|
||
} else {
|
||
applyBadgeToDom(name, null);
|
||
}
|
||
}
|
||
|
||
export async function refreshViewedBadges(folder) {
|
||
let map = null;
|
||
try {
|
||
const res = await fetch(withBase(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`), { credentials: 'include' });
|
||
const j = await res.json();
|
||
map = j?.map || null;
|
||
} catch (e) { /* ignore */ }
|
||
|
||
// Clear any existing badges
|
||
document.querySelectorAll(
|
||
'#fileList tr[data-file-name] .file-name-cell .status-badge, ' +
|
||
'#fileList tr[data-file-name] .name-cell .status-badge, ' +
|
||
'.gallery-card[data-file-name] .gallery-file-name .status-badge'
|
||
).forEach(n => n.remove());
|
||
|
||
if (!map) return;
|
||
|
||
// Table rows
|
||
document.querySelectorAll('#fileList tr[data-file-name]').forEach(tr => {
|
||
const name = tr.getAttribute('data-file-name');
|
||
const state = map[name];
|
||
if (!state) return;
|
||
const cell = tr.querySelector('.name-cell, .file-name-cell');
|
||
if (!cell) return;
|
||
const badge = makeBadge(state);
|
||
if (badge) cell.appendChild(badge);
|
||
});
|
||
|
||
// Gallery cards
|
||
document.querySelectorAll('.gallery-card[data-file-name]').forEach(card => {
|
||
const name = card.getAttribute('data-file-name');
|
||
const state = map[name];
|
||
if (!state) return;
|
||
const title = card.querySelector('.gallery-file-name');
|
||
if (!title) return;
|
||
const badge = makeBadge(state);
|
||
if (badge) title.appendChild(badge);
|
||
});
|
||
}
|
||
/**
|
||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||
*/
|
||
function parseSizeToBytes(sizeStr) {
|
||
if (!sizeStr) return 0;
|
||
let s = sizeStr.trim();
|
||
let value = parseFloat(s);
|
||
let upper = s.toUpperCase();
|
||
if (upper.includes("KB")) {
|
||
value *= 1024;
|
||
} else if (upper.includes("MB")) {
|
||
value *= 1024 * 1024;
|
||
} else if (upper.includes("GB")) {
|
||
value *= 1024 * 1024 * 1024;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Format the total bytes as a human-readable string.
|
||
*/
|
||
function formatSize(totalBytes) {
|
||
if (!Number.isFinite(totalBytes) || totalBytes < 0) return "";
|
||
|
||
if (totalBytes < 1024) {
|
||
return totalBytes + " B";
|
||
} else if (totalBytes < 1024 * 1024) {
|
||
return (totalBytes / 1024).toFixed(1) + " KB";
|
||
} else if (totalBytes < 1024 * 1024 * 1024) {
|
||
return (totalBytes / (1024 * 1024)).toFixed(1) + " MB";
|
||
} else {
|
||
return (totalBytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
|
||
}
|
||
}
|
||
|
||
|
||
function ensureNonZipDownloadPanel() {
|
||
if (window.__nonZipDownloadPanel) return window.__nonZipDownloadPanel;
|
||
|
||
const panel = document.createElement('div');
|
||
panel.id = 'nonZipDownloadPanel';
|
||
panel.setAttribute('role', 'status');
|
||
|
||
// Simple bottom-right card using Bootstrap-ish styles + inline layout tweaks
|
||
panel.style.position = 'fixed';
|
||
panel.style.top = '50%';
|
||
panel.style.left = '50%';
|
||
panel.style.transform = 'translate(-50%, -50%)';
|
||
panel.style.zIndex = '9999';
|
||
panel.style.width = 'min(440px, 95vw)';
|
||
panel.style.minWidth = '280px';
|
||
panel.style.maxWidth = '440px';
|
||
panel.style.padding = '14px 16px';
|
||
panel.style.borderRadius = '12px';
|
||
panel.style.boxShadow = '0 18px 40px rgba(0,0,0,0.35)';
|
||
panel.style.backgroundColor = 'var(--filr-menu-bg, #222)';
|
||
panel.style.color = 'var(--filr-menu-fg, #f9fafb)';
|
||
panel.style.fontSize = '0.9rem';
|
||
panel.style.display = 'none';
|
||
|
||
panel.innerHTML = `
|
||
<div class="nonzip-title" style="margin-bottom:6px; font-weight:600;"></div>
|
||
<div class="nonzip-sub" style="margin-bottom:8px; opacity:0.85;"></div>
|
||
<div class="nonzip-actions" style="display:flex; justify-content:flex-end; gap:6px;">
|
||
<button type="button"
|
||
class="btn btn-sm btn-secondary nonzip-cancel-btn">
|
||
${t('cancel') || 'Cancel'}
|
||
</button>
|
||
<button type="button"
|
||
class="btn btn-sm btn-primary nonzip-next-btn">
|
||
${t('download_next') || 'Download next'}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(panel);
|
||
|
||
const nextBtn = panel.querySelector('.nonzip-next-btn');
|
||
const cancelBtn = panel.querySelector('.nonzip-cancel-btn');
|
||
|
||
if (nextBtn) {
|
||
nextBtn.addEventListener('click', () => {
|
||
triggerNextNonZipDownload();
|
||
});
|
||
}
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener('click', () => {
|
||
clearNonZipQueue(true);
|
||
});
|
||
}
|
||
|
||
window.__nonZipDownloadPanel = panel;
|
||
return panel;
|
||
}
|
||
|
||
function updateNonZipPanelText() {
|
||
const panel = ensureNonZipDownloadPanel();
|
||
const q = window.__nonZipDownloadQueue || [];
|
||
const count = q.length;
|
||
|
||
const titleEl = panel.querySelector('.nonzip-title');
|
||
const subEl = panel.querySelector('.nonzip-sub');
|
||
|
||
if (!titleEl || !subEl) return;
|
||
|
||
if (!count) {
|
||
titleEl.textContent = t('no_files_queued') || 'No files queued.';
|
||
subEl.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const title =
|
||
t('nonzip_queue_title') ||
|
||
'Files queued for download';
|
||
|
||
const raw = t('nonzip_queue_subtitle') ||
|
||
'{count} files queued. Click "Download next" for each file.';
|
||
|
||
const msg = raw.replace('{count}', String(count));
|
||
|
||
titleEl.textContent = title;
|
||
subEl.textContent = msg;
|
||
}
|
||
|
||
function showNonZipPanel() {
|
||
const panel = ensureNonZipDownloadPanel();
|
||
updateNonZipPanelText();
|
||
panel.style.display = 'block';
|
||
}
|
||
|
||
function hideNonZipPanel() {
|
||
const panel = ensureNonZipDownloadPanel();
|
||
panel.style.display = 'none';
|
||
}
|
||
|
||
function clearNonZipQueue(showToastCancel = false) {
|
||
window.__nonZipDownloadQueue = [];
|
||
hideNonZipPanel();
|
||
if (showToastCancel) {
|
||
showToast(
|
||
t('nonzip_queue_cleared') || 'Download queue cleared.',
|
||
'info'
|
||
);
|
||
}
|
||
}
|
||
|
||
function triggerNextNonZipDownload() {
|
||
const q = window.__nonZipDownloadQueue || [];
|
||
if (!q.length) {
|
||
hideNonZipPanel();
|
||
showToast(
|
||
t('downloads_started') || 'All downloads started.',
|
||
'success'
|
||
);
|
||
return;
|
||
}
|
||
|
||
const { folder, name, sourceId } = q.shift();
|
||
const url = apiFileUrl(folder || 'root', name, /* inline */ false, sourceId);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = name;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
|
||
try {
|
||
a.click();
|
||
} finally {
|
||
setTimeout(() => {
|
||
if (a && a.parentNode) {
|
||
a.parentNode.removeChild(a);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
// Update queue + UI
|
||
window.__nonZipDownloadQueue = q;
|
||
if (q.length) {
|
||
updateNonZipPanelText();
|
||
} else {
|
||
hideNonZipPanel();
|
||
showToast(
|
||
t('downloads_started') || 'All downloads started.',
|
||
'success'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Optional debug helpers if you want them globally:
|
||
window.triggerNextNonZipDownload = triggerNextNonZipDownload;
|
||
window.clearNonZipQueue = clearNonZipQueue;
|
||
|
||
|
||
function getLocalSummaryStats() {
|
||
const files = Array.isArray(fileData) ? fileData.length : 0;
|
||
const bytes = Array.isArray(fileData)
|
||
? fileData.reduce((sum, file) => {
|
||
const b = Number.isFinite(file.sizeBytes)
|
||
? file.sizeBytes
|
||
: parseSizeToBytes(file.size || "");
|
||
return sum + (Number.isFinite(b) && b > 0 ? b : 0);
|
||
}, 0)
|
||
: 0;
|
||
const folders = Array.isArray(window.currentSubfolders)
|
||
? window.currentSubfolders.length
|
||
: 0;
|
||
return { folders, files, bytes };
|
||
}
|
||
|
||
function buildFolderSummaryHTML(stats, fallback) {
|
||
const safeTitle = (key, fallbackText) => {
|
||
const val = t(key);
|
||
return val && val !== key ? val : fallbackText;
|
||
};
|
||
const fmtCount = (val) => Number.isFinite(val) ? val.toLocaleString() : "-";
|
||
|
||
const folders = Number.isFinite(stats?.folders) ? stats.folders : fallback?.folders;
|
||
const files = Number.isFinite(stats?.files) ? stats.files : fallback?.files;
|
||
const bytes = Number.isFinite(stats?.bytes) ? stats.bytes : fallback?.bytes;
|
||
|
||
const folderTitle = safeTitle("total_folders", "Total folders");
|
||
const fileTitle = safeTitle("total_files", "Total files");
|
||
const sizeTitle = safeTitle("total_size", "Total size");
|
||
|
||
const sizeStr = Number.isFinite(bytes) ? formatSize(bytes) : "-";
|
||
|
||
return `
|
||
<span class="fr-summary-item" title="${escapeHTML(folderTitle)}">
|
||
<span class="material-icons" aria-hidden="true">folder</span>
|
||
<span class="fr-summary-count">${fmtCount(folders)}</span>
|
||
</span>
|
||
<span class="fr-summary-sep" aria-hidden="true">·</span>
|
||
<span class="fr-summary-item" title="${escapeHTML(fileTitle)}">
|
||
<span class="material-icons" aria-hidden="true">insert_drive_file</span>
|
||
<span class="fr-summary-count">${fmtCount(files)}</span>
|
||
</span>
|
||
<span class="fr-summary-sep" aria-hidden="true">·</span>
|
||
<span class="fr-summary-item" title="${escapeHTML(sizeTitle)}">
|
||
<span class="material-icons" aria-hidden="true">storage</span>
|
||
<span class="fr-summary-count">${escapeHTML(sizeStr)}</span>
|
||
</span>
|
||
`.trim();
|
||
}
|
||
|
||
function refreshFileSummaryForPane(paneKey) {
|
||
const actionsContainer = document.getElementById("fileListActions");
|
||
if (!actionsContainer) return;
|
||
|
||
let summaryElem = document.getElementById("fileSummary");
|
||
if (!summaryElem) {
|
||
summaryElem = document.createElement("div");
|
||
summaryElem.id = "fileSummary";
|
||
summaryElem.className = "fr-summary-pill";
|
||
summaryElem.setAttribute("role", "status");
|
||
summaryElem.setAttribute("aria-live", "polite");
|
||
actionsContainer.appendChild(summaryElem);
|
||
}
|
||
|
||
const state = paneKey ? getPaneState(paneKey) : null;
|
||
if (!state || !state.hasLoaded) {
|
||
summaryElem.style.display = "none";
|
||
summaryElem.innerHTML = "";
|
||
summaryElem.dataset.folder = "";
|
||
summaryElem.dataset.truncated = "0";
|
||
summaryElem.removeAttribute("title");
|
||
return;
|
||
}
|
||
|
||
const folder = state.currentFolder || window.currentFolder || "root";
|
||
const sourceId = state.sourceId || getActivePaneSourceId();
|
||
summaryElem.classList.add("fr-summary-pill");
|
||
summaryElem.style.display = "flex";
|
||
const fallbackStats = getLocalSummaryStats();
|
||
summaryElem.innerHTML = buildFolderSummaryHTML(null, fallbackStats);
|
||
summaryElem.dataset.folder = folder;
|
||
summaryElem.dataset.truncated = "0";
|
||
|
||
fetchFolderSummaryStats(folder, undefined, sourceId).then(stats => {
|
||
if (!stats || summaryElem.dataset.folder !== folder) return;
|
||
summaryElem.innerHTML = buildFolderSummaryHTML(stats, fallbackStats);
|
||
summaryElem.dataset.truncated = stats.truncated ? "1" : "0";
|
||
if (stats.truncated) {
|
||
summaryElem.title = "Totals capped for very large folders.";
|
||
} else {
|
||
summaryElem.removeAttribute("title");
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Advanced Search toggle
|
||
*/
|
||
function toggleAdvancedSearch() {
|
||
window.advancedSearchEnabled = !window.advancedSearchEnabled;
|
||
const advancedBtn = document.getElementById("advancedSearchToggle");
|
||
if (advancedBtn) {
|
||
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
|
||
}
|
||
const pane = normalizePaneKey(window.activePane);
|
||
const folder = window.currentFolder || "root";
|
||
if (window.advancedSearchEnabled) {
|
||
loadFileList(folder, { pane, forceLegacy: true, includeContent: true });
|
||
return;
|
||
}
|
||
const trimmed = String(window.currentSearchTerm || '').trim();
|
||
if (trimmed === '' && shouldUseServerFilePagingForRequest({})) {
|
||
loadFileList(folder, { pane });
|
||
return;
|
||
}
|
||
if (window.viewMode === "gallery") {
|
||
renderGalleryView(folder);
|
||
} else {
|
||
renderFileTable(folder);
|
||
}
|
||
}
|
||
|
||
window.imageCache = window.imageCache || {};
|
||
function cacheImage(imgElem, key) {
|
||
window.imageCache[key] = imgElem.src;
|
||
}
|
||
window.cacheImage = cacheImage;
|
||
|
||
/**
|
||
* Fuse.js fuzzy search helper
|
||
*/
|
||
// --- Lazy Fuse loader (drop-in, CSP-safe, no inline) ---
|
||
const FUSE_SRC = withBase('/vendor/fuse/7.1.0/fuse.min.js?v={{APP_QVER}}');
|
||
let _fuseLoadingPromise = null;
|
||
|
||
function loadScriptOnce(src) {
|
||
// cache by src so we don't append multiple <script> tags
|
||
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
|
||
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
|
||
const p = new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = src;
|
||
s.async = true;
|
||
s.onload = resolve;
|
||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||
document.head.appendChild(s);
|
||
});
|
||
loadScriptOnce._cache.set(src, p);
|
||
return p;
|
||
}
|
||
|
||
function lazyLoadFuse() {
|
||
if (window.Fuse) return Promise.resolve(window.Fuse);
|
||
if (!_fuseLoadingPromise) {
|
||
_fuseLoadingPromise = loadScriptOnce(FUSE_SRC).then(() => window.Fuse);
|
||
}
|
||
return _fuseLoadingPromise;
|
||
}
|
||
|
||
// (Optional) warm-up call you can trigger from main.js after first render:
|
||
// import { warmUpSearch } from './fileListView.js?v={{APP_QVER}}';
|
||
// warmUpSearch();
|
||
// This just starts fetching Fuse in the background.
|
||
export function warmUpSearch() {
|
||
lazyLoadFuse().catch(() => {/* ignore; we’ll fall back */ });
|
||
}
|
||
|
||
// Lazy + backward-compatible search
|
||
function searchFiles(searchTerm) {
|
||
if (!searchTerm) return fileData;
|
||
|
||
// kick off Fuse load in the background, but don't await
|
||
lazyLoadFuse().catch(() => { /* ignore */ });
|
||
|
||
// keys config (matches your original)
|
||
const fuseKeys = [
|
||
{ name: 'name', weight: 0.1 },
|
||
{ name: 'uploader', weight: 0.1 },
|
||
{ name: 'tags.name', weight: 0.1 }
|
||
];
|
||
if (window.advancedSearchEnabled) {
|
||
fuseKeys.push({ name: 'content', weight: 0.7 });
|
||
}
|
||
|
||
// If Fuse is present, use it right away (synchronous API)
|
||
if (window.Fuse) {
|
||
const options = {
|
||
keys: fuseKeys,
|
||
threshold: 0.4,
|
||
minMatchCharLength: 2,
|
||
ignoreLocation: true
|
||
};
|
||
const fuse = new window.Fuse(fileData, options);
|
||
const results = fuse.search(searchTerm);
|
||
return results.map(r => r.item);
|
||
}
|
||
|
||
// Fallback (first keystrokes before Fuse finishes loading):
|
||
// simple case-insensitive substring match on the same fields
|
||
const q = String(searchTerm).toLowerCase();
|
||
const hay = (v) => (v == null ? '' : String(v)).toLowerCase();
|
||
return fileData.filter(item => {
|
||
if (hay(item.name).includes(q)) return true;
|
||
if (hay(item.uploader).includes(q)) return true;
|
||
if (Array.isArray(item.tags) && item.tags.some(t => hay(t?.name).includes(q))) return true;
|
||
if (window.advancedSearchEnabled && hay(item.content).includes(q)) return true;
|
||
return false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* View mode toggle
|
||
*/
|
||
export function createViewToggleButton() {
|
||
let toggleBtn = document.getElementById("toggleViewBtn");
|
||
if (!toggleBtn) {
|
||
toggleBtn = document.createElement("button");
|
||
toggleBtn.id = "toggleViewBtn";
|
||
toggleBtn.classList.add("btn", "action-btn", "icon-only", "btn-light");
|
||
|
||
if (window.viewMode === "gallery") {
|
||
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
||
toggleBtn.title = t("switch_to_table_view");
|
||
} else {
|
||
toggleBtn.innerHTML = '<i class="material-icons">view_module</i>';
|
||
toggleBtn.title = t("switch_to_gallery_view");
|
||
}
|
||
|
||
const actionsBar = document.getElementById("fileActionsBar");
|
||
const headerButtons = document.querySelector(".header-buttons");
|
||
|
||
if (actionsBar) {
|
||
actionsBar.appendChild(toggleBtn);
|
||
} else if (headerButtons && headerButtons.lastElementChild) {
|
||
headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild);
|
||
} else if (headerButtons) {
|
||
headerButtons.appendChild(toggleBtn);
|
||
}
|
||
} else {
|
||
const actionsBar = document.getElementById("fileActionsBar");
|
||
if (actionsBar && toggleBtn.parentElement !== actionsBar) {
|
||
actionsBar.appendChild(toggleBtn);
|
||
}
|
||
}
|
||
|
||
toggleBtn.onclick = () => {
|
||
hideViewOptionsPopover();
|
||
window.viewMode = window.viewMode === "gallery" ? "table" : "gallery";
|
||
localStorage.setItem("viewMode", window.viewMode);
|
||
loadFileList(window.currentFolder);
|
||
if (window.viewMode === "gallery") {
|
||
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
|
||
toggleBtn.title = t("switch_to_table_view");
|
||
} else {
|
||
toggleBtn.innerHTML = '<i class="material-icons">view_module</i>';
|
||
toggleBtn.title = t("switch_to_gallery_view");
|
||
}
|
||
};
|
||
|
||
return toggleBtn;
|
||
}
|
||
|
||
function ensureSearchEverywhereButton() {
|
||
const cfg = window.__FR_PRO_SEARCH_CFG__ || {};
|
||
const isPro = window.__FR_IS_PRO === true;
|
||
const ver = window.__FR_PRO_VERSION || '';
|
||
const hasMinVersion = isPro && compareSemverLite(ver, '1.3.0') >= 0;
|
||
const enabled = isPro && hasMinVersion && !!cfg.enabled;
|
||
const actionsBar = document.getElementById("fileActionsBar");
|
||
if (!enabled || !actionsBar) {
|
||
if (searchEverywhereBtn && searchEverywhereBtn.parentElement) {
|
||
searchEverywhereBtn.parentElement.removeChild(searchEverywhereBtn);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (!searchEverywhereBtn) {
|
||
searchEverywhereBtn = document.createElement("button");
|
||
searchEverywhereBtn.id = "searchEverywhereBtn";
|
||
searchEverywhereBtn.className = "btn btn-light action-btn";
|
||
searchEverywhereBtn.innerHTML = `
|
||
<i class="material-icons" aria-hidden="true">travel_explore</i>
|
||
<span style="margin-left:4px;">${t("search_everywhere_label") || "Search everywhere"}</span>
|
||
`;
|
||
searchEverywhereBtn.addEventListener("click", openSearchEverywhereModal);
|
||
}
|
||
|
||
if (searchEverywhereBtn.parentElement !== actionsBar) {
|
||
actionsBar.appendChild(searchEverywhereBtn);
|
||
}
|
||
return searchEverywhereBtn;
|
||
}
|
||
|
||
function getSearchEverywhereSources() {
|
||
const sources = [];
|
||
const select = document.getElementById('sourceSelector');
|
||
if (select && select.options && select.options.length) {
|
||
Array.from(select.options).forEach(opt => {
|
||
const id = String(opt.value || '').trim();
|
||
if (!id) return;
|
||
const name = String(opt.dataset?.sourceName || opt.textContent || id);
|
||
const type = String(opt.dataset?.sourceType || '');
|
||
const readOnly = opt.dataset?.sourceReadOnly === '1' || opt.disabled;
|
||
sources.push({ id, name, type, readOnly });
|
||
});
|
||
return sources;
|
||
}
|
||
|
||
try {
|
||
const metaMap = window.__FR_SOURCE_META_MAP;
|
||
if (metaMap && typeof metaMap === 'object') {
|
||
Object.keys(metaMap).forEach(id => {
|
||
const meta = metaMap[id] || {};
|
||
sources.push({ id, name: meta.name || id, type: meta.type || '', readOnly: !!meta.readOnly });
|
||
});
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
|
||
return sources;
|
||
}
|
||
|
||
function populateSearchEverywhereSources() {
|
||
if (!searchEverywhereSourceEl) return;
|
||
const sources = getSearchEverywhereSources();
|
||
const wrap = searchEverywhereSourceEl.closest('.search-everywhere-source-wrap');
|
||
searchEverywhereSourceEl.innerHTML = '';
|
||
|
||
const multiple = sources.length > 1;
|
||
if (multiple) {
|
||
const opt = document.createElement('option');
|
||
opt.value = 'all';
|
||
opt.textContent = t('search_everywhere_source_all') || 'All sources';
|
||
searchEverywhereSourceEl.appendChild(opt);
|
||
}
|
||
|
||
sources.forEach(src => {
|
||
const id = String(src.id || '').trim();
|
||
if (!id) return;
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
const label = formatSourceBadgeText(src) || id;
|
||
const lockMark = src.readOnly ? ` \uD83D\uDD12 ${t('read_only') || 'Read-only'}` : '';
|
||
opt.textContent = `${label}${lockMark}`;
|
||
searchEverywhereSourceEl.appendChild(opt);
|
||
});
|
||
|
||
if (wrap) {
|
||
wrap.style.display = (multiple || sources.length > 0) ? '' : 'none';
|
||
}
|
||
searchEverywhereSourceEl.disabled = sources.length <= 1;
|
||
|
||
const prev = searchEverywhereSourceEl.getAttribute('data-prev') || '';
|
||
const hasPrev = Array.from(searchEverywhereSourceEl.options).some(opt => opt.value === prev);
|
||
if (hasPrev) {
|
||
searchEverywhereSourceEl.value = prev;
|
||
} else if (multiple) {
|
||
searchEverywhereSourceEl.value = 'all';
|
||
} else if (searchEverywhereSourceEl.options.length) {
|
||
searchEverywhereSourceEl.value = searchEverywhereSourceEl.options[0].value;
|
||
}
|
||
searchEverywhereSourceEl.setAttribute('data-prev', searchEverywhereSourceEl.value || '');
|
||
|
||
if (!searchEverywhereSourceEl.__wired) {
|
||
searchEverywhereSourceEl.__wired = true;
|
||
searchEverywhereSourceEl.addEventListener('change', () => {
|
||
searchEverywhereSourceEl.setAttribute('data-prev', searchEverywhereSourceEl.value || '');
|
||
});
|
||
}
|
||
}
|
||
|
||
function ensureSearchEverywhereModal() {
|
||
if (searchEverywhereModal) return searchEverywhereModal;
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "searchEverywhereModal";
|
||
overlay.style.cssText = `
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
z-index: 4000;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
`;
|
||
|
||
const card = document.createElement("div");
|
||
card.className = "search-everywhere-card";
|
||
card.style.cssText = `
|
||
max-width: 720px;
|
||
width: 100%;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
position: relative;
|
||
box-shadow: 0 12px 35px rgba(0,0,0,0.35);
|
||
`;
|
||
card.innerHTML = `
|
||
<button type="button"
|
||
class="editor-close-btn"
|
||
aria-label="${t("close") || "Close"}"
|
||
id="searchEverywhereClose"
|
||
style="top:8px; right:8px;">×</button>
|
||
<div class="d-flex align-items-center" style="gap:10px; margin-bottom:10px;">
|
||
<i class="material-icons" aria-hidden="true">travel_explore</i>
|
||
<div>
|
||
<div style="font-weight:600;">${t("search_everywhere_title") || "Search Everywhere"}</div>
|
||
<div class="text-muted" style="font-size:12px;">${t("search_everywhere_desc") || "ACL-aware search across all folders you can access."}</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:8px;">
|
||
<input type="text" id="searchEverywhereInput" class="form-control" placeholder="${t("search_everywhere_placeholder") || "Type to search across all folders"}" autocomplete="off" />
|
||
</div>
|
||
<div class="d-flex align-items-center" style="gap:8px; margin-bottom:10px; flex-wrap: wrap;">
|
||
<div class="search-everywhere-source-wrap" style="display:flex; align-items:center; gap:6px;">
|
||
<label for="searchEverywhereSource" class="mb-0" style="font-size:12px;">${t("search_everywhere_source_label") || "Source"}</label>
|
||
<select id="searchEverywhereSource" class="form-control" style="max-width:220px;"></select>
|
||
</div>
|
||
<label for="searchEverywhereLimit" class="mb-0" style="font-size:12px;">${t("search_everywhere_limit") || "Result limit"}</label>
|
||
<input type="number" id="searchEverywhereLimit" class="form-control" style="max-width:100px;" min="1" max="200" />
|
||
<button type="button" class="btn btn-primary btn-sm" id="searchEverywhereRun">${t("search_everywhere_run") || "Search"}</button>
|
||
</div>
|
||
<div id="searchEverywhereResults" style="max-height:320px; overflow:auto; border:1px solid #e0e0e0; border-radius:10px; padding:8px; background:rgba(0,0,0,0.02);">
|
||
<div class="text-muted" style="font-size:12px;">${t("search_everywhere_hint") || "Results will appear here."}</div>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
searchEverywhereCard = card;
|
||
const closeBtn = card.querySelector("#searchEverywhereClose");
|
||
closeBtn?.addEventListener("click", closeSearchEverywhereModal);
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) closeSearchEverywhereModal();
|
||
});
|
||
|
||
searchEverywhereModal = overlay;
|
||
searchEverywhereResultsEl = card.querySelector("#searchEverywhereResults");
|
||
searchEverywhereInputEl = card.querySelector("#searchEverywhereInput");
|
||
searchEverywhereLimitEl = card.querySelector("#searchEverywhereLimit");
|
||
searchEverywhereSourceEl = card.querySelector("#searchEverywhereSource");
|
||
populateSearchEverywhereSources();
|
||
|
||
const runBtn = card.querySelector("#searchEverywhereRun");
|
||
runBtn?.addEventListener("click", () => {
|
||
if (searchEverywhereInputEl) {
|
||
runSearchEverywhere(searchEverywhereInputEl.value || '');
|
||
}
|
||
});
|
||
searchEverywhereInputEl?.addEventListener("keydown", (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
runSearchEverywhere(searchEverywhereInputEl.value || '');
|
||
}
|
||
});
|
||
|
||
const dmToggle = document.getElementById('darkModeToggle');
|
||
if (dmToggle && !dmToggle.__searchEverywhereTheme) {
|
||
dmToggle.__searchEverywhereTheme = true;
|
||
dmToggle.addEventListener('click', () => applySearchEverywhereTheme());
|
||
}
|
||
applySearchEverywhereTheme();
|
||
|
||
return searchEverywhereModal;
|
||
}
|
||
|
||
function applySearchEverywhereTheme() {
|
||
if (!searchEverywhereModal) return;
|
||
const overlay = searchEverywhereModal;
|
||
const card = searchEverywhereCard || overlay.querySelector(".search-everywhere-card");
|
||
const isDark = document.body.classList.contains("dark-mode");
|
||
|
||
overlay.style.background = isDark ? "rgba(0,0,0,0.72)" : "rgba(0,0,0,0.55)";
|
||
if (card) {
|
||
card.style.background = isDark ? "#141414" : "#ffffff";
|
||
card.style.color = isDark ? "#f4f4f4" : "#000000";
|
||
card.style.boxShadow = isDark ? "0 18px 45px rgba(0,0,0,0.65)" : "0 12px 35px rgba(0,0,0,0.2)";
|
||
card.style.border = isDark ? "1px solid rgba(255,255,255,0.08)" : "1px solid #e0e0e0";
|
||
}
|
||
|
||
if (searchEverywhereResultsEl) {
|
||
searchEverywhereResultsEl.style.border = isDark ? "1px solid rgba(255,255,255,0.12)" : "1px solid #e0e0e0";
|
||
searchEverywhereResultsEl.style.background = isDark ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)";
|
||
}
|
||
|
||
const inputs = searchEverywhereModal.querySelectorAll("#searchEverywhereInput, #searchEverywhereLimit, #searchEverywhereSource");
|
||
inputs.forEach((inp) => {
|
||
if (isDark) {
|
||
inp.style.background = "#1f1f1f";
|
||
inp.style.color = "#f4f4f4";
|
||
inp.style.borderColor = "#444";
|
||
} else {
|
||
inp.style.background = "";
|
||
inp.style.color = "";
|
||
inp.style.borderColor = "";
|
||
}
|
||
});
|
||
|
||
searchEverywhereModal.querySelectorAll(".text-muted").forEach((el) => {
|
||
el.style.color = isDark ? "rgba(255,255,255,0.72)" : "";
|
||
});
|
||
}
|
||
|
||
function openSearchEverywhereModal() {
|
||
ensureSearchEverywhereModal();
|
||
if (!searchEverywhereModal) return;
|
||
const cfg = window.__FR_PRO_SEARCH_CFG__ || {};
|
||
const limit = Math.max(1, Math.min(200, Number(cfg.defaultLimit || 50)));
|
||
if (searchEverywhereLimitEl) {
|
||
searchEverywhereLimitEl.value = String(limit);
|
||
}
|
||
populateSearchEverywhereSources();
|
||
applySearchEverywhereTheme();
|
||
searchEverywhereModal.style.display = "flex";
|
||
requestAnimationFrame(() => {
|
||
searchEverywhereInputEl?.focus();
|
||
});
|
||
}
|
||
|
||
function closeSearchEverywhereModal() {
|
||
if (searchEverywhereModal) {
|
||
searchEverywhereModal.style.display = "none";
|
||
}
|
||
}
|
||
|
||
function ensureSearchEverywhereResultBlocks() {
|
||
if (!searchEverywhereResultsEl) {
|
||
searchEverywhereStatusEl = null;
|
||
searchEverywhereListEl = null;
|
||
return { statusEl: null, listEl: null };
|
||
}
|
||
|
||
let statusEl = searchEverywhereResultsEl.querySelector("#searchEverywhereStatus");
|
||
let listEl = searchEverywhereResultsEl.querySelector("#searchEverywhereList");
|
||
if (!statusEl || !listEl) {
|
||
searchEverywhereResultsEl.innerHTML = `
|
||
<div id="searchEverywhereStatus" class="text-muted" style="font-size:12px; margin-bottom:6px;"></div>
|
||
<div id="searchEverywhereList"></div>
|
||
`;
|
||
statusEl = searchEverywhereResultsEl.querySelector("#searchEverywhereStatus");
|
||
listEl = searchEverywhereResultsEl.querySelector("#searchEverywhereList");
|
||
}
|
||
|
||
searchEverywhereStatusEl = statusEl;
|
||
searchEverywhereListEl = listEl;
|
||
return { statusEl, listEl };
|
||
}
|
||
|
||
function sortSearchEverywhereItems(items) {
|
||
const list = Array.isArray(items) ? items.slice() : [];
|
||
list.sort((a, b) => {
|
||
const saRaw = Number(a?.score);
|
||
const sbRaw = Number(b?.score);
|
||
const sa = Number.isFinite(saRaw) ? saRaw : 999;
|
||
const sb = Number.isFinite(sbRaw) ? sbRaw : 999;
|
||
if (sa === sb) {
|
||
const pa = `${String(a?.sourceId || '')}::${String(a?.path || '')}`;
|
||
const pb = `${String(b?.sourceId || '')}::${String(b?.path || '')}`;
|
||
return pa.localeCompare(pb, undefined, { sensitivity: "base", numeric: true });
|
||
}
|
||
return sa - sb;
|
||
});
|
||
return list;
|
||
}
|
||
|
||
function renderSearchEverywhereResults(items) {
|
||
if (!searchEverywhereResultsEl) return;
|
||
const listEl = searchEverywhereResultsEl.querySelector("#searchEverywhereList") || searchEverywhereResultsEl;
|
||
if (!Array.isArray(items) || !items.length) {
|
||
listEl.innerHTML = `<div class="text-muted" style="font-size:12px;">${t("search_everywhere_no_results") || "No results"}</div>`;
|
||
return;
|
||
}
|
||
|
||
const rows = items.map((item) => {
|
||
const icon = item.type === 'folder' ? 'folder' : 'insert_drive_file';
|
||
const name = escapeHTML(item.name || '');
|
||
const path = escapeHTML(item.path || '');
|
||
const sourceId = String(item.sourceId || '').trim();
|
||
const sourceMeta = {
|
||
name: String(item.sourceName || ''),
|
||
type: String(item.sourceType || '')
|
||
};
|
||
let sourceLabel = formatSourceBadgeText(sourceMeta);
|
||
if (!sourceLabel && sourceId) {
|
||
sourceLabel = formatSourceBadgeText(getSourceMetaById(sourceId));
|
||
}
|
||
const sourceText = sourceLabel ? escapeHTML(sourceLabel) : '';
|
||
const uploaderText = item.uploader ? escapeHTML(`Uploaded by ${item.uploader}`) : '';
|
||
const metaLine = [path, sourceText, uploaderText].filter(Boolean).join(' • ');
|
||
return `
|
||
<div class="d-flex align-items-center search-everywhere-row" data-path="${path}" data-folder="${escapeHTML(item.folder || '')}" data-name="${escapeHTML(item.name || '')}" data-type="${item.type}" data-source-id="${escapeHTML(sourceId)}" style="padding:6px 4px; border-bottom:1px solid rgba(0,0,0,0.05); cursor:pointer;">
|
||
<i class="material-icons" aria-hidden="true" style="margin-right:8px;">${icon}</i>
|
||
<div style="flex:1; min-width:0;">
|
||
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${name}</div>
|
||
${metaLine ? `<div class="text-muted" style="font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${metaLine}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
listEl.innerHTML = rows;
|
||
listEl.querySelectorAll('.search-everywhere-row').forEach((row) => {
|
||
row.addEventListener('click', () => {
|
||
const folder = row.getAttribute('data-folder') || 'root';
|
||
const name = row.getAttribute('data-name') || '';
|
||
const sourceId = row.getAttribute('data-source-id') || '';
|
||
navigateToSearchResult(folder, name, sourceId);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runSearchEverywhereAcrossSources(term, limit, sources, runId) {
|
||
const uniqSources = [];
|
||
const seen = new Set();
|
||
sources.forEach((src) => {
|
||
const id = String(src?.id || '').trim();
|
||
if (!id || seen.has(id)) return;
|
||
seen.add(id);
|
||
uniqSources.push({
|
||
id,
|
||
name: String(src?.name || id),
|
||
type: String(src?.type || ''),
|
||
});
|
||
});
|
||
|
||
if (!uniqSources.length) {
|
||
renderSearchEverywhereResults([]);
|
||
if (searchEverywhereStatusEl) searchEverywhereStatusEl.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const perSourceLimit = Math.min(limit, 50);
|
||
const statusById = {};
|
||
const labelById = {};
|
||
uniqSources.forEach((src) => {
|
||
statusById[src.id] = 'pending';
|
||
labelById[src.id] = formatSourceBadgeText(src) || src.name || src.id;
|
||
});
|
||
|
||
const resultsMap = new Map();
|
||
const reindexedSources = new Set();
|
||
|
||
const updateStatus = () => {
|
||
if (!searchEverywhereStatusEl) return;
|
||
const ids = Object.keys(statusById);
|
||
if (!ids.length) {
|
||
searchEverywhereStatusEl.textContent = '';
|
||
return;
|
||
}
|
||
const doneCount = ids.filter(id => statusById[id] !== 'pending').length;
|
||
const pendingLabels = ids.filter(id => statusById[id] === 'pending').map(id => labelById[id]).filter(Boolean);
|
||
const failedLabels = ids.filter(id => statusById[id] === 'error').map(id => labelById[id]).filter(Boolean);
|
||
|
||
if (doneCount >= ids.length && pendingLabels.length === 0) {
|
||
if (failedLabels.length) {
|
||
searchEverywhereStatusEl.textContent = `Failed: ${failedLabels.join(', ')}`;
|
||
} else {
|
||
searchEverywhereStatusEl.textContent = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
let text = `${t("loading") || "Loading..."} (${doneCount}/${ids.length})`;
|
||
if (pendingLabels.length) {
|
||
text += ` - Waiting on: ${pendingLabels.join(', ')}`;
|
||
}
|
||
if (failedLabels.length) {
|
||
text += ` - Failed: ${failedLabels.join(', ')}`;
|
||
}
|
||
searchEverywhereStatusEl.textContent = text;
|
||
};
|
||
|
||
updateStatus();
|
||
|
||
const fetchOne = async (src) => {
|
||
const sourceParam = `&sourceId=${encodeURIComponent(src.id)}`;
|
||
try {
|
||
const res = await fetch(withBase(`/api/pro/search/query.php?q=${encodeURIComponent(term)}&limit=${perSourceLimit}${sourceParam}`), {
|
||
credentials: 'include',
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.ok) {
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
if (runId !== searchEverywhereRunId) return;
|
||
statusById[src.id] = 'done';
|
||
if (data.reindexed) reindexedSources.add(src.id);
|
||
const items = Array.isArray(data.items) ? data.items : [];
|
||
items.forEach((item) => {
|
||
const sourceKey = String(item?.sourceId || src.id || '');
|
||
const pathKey = String(item?.path || item?.folder || item?.name || '');
|
||
const key = `${sourceKey}::${pathKey}`;
|
||
if (!item?.sourceId) item.sourceId = sourceKey;
|
||
resultsMap.set(key, item);
|
||
});
|
||
const sorted = sortSearchEverywhereItems(Array.from(resultsMap.values()));
|
||
renderSearchEverywhereResults(sorted.slice(0, limit));
|
||
} catch (e) {
|
||
if (runId !== searchEverywhereRunId) return;
|
||
statusById[src.id] = 'error';
|
||
} finally {
|
||
if (runId !== searchEverywhereRunId) return;
|
||
updateStatus();
|
||
}
|
||
};
|
||
|
||
await Promise.allSettled(uniqSources.map(src => fetchOne(src)));
|
||
|
||
if (runId !== searchEverywhereRunId) return;
|
||
if (!resultsMap.size) {
|
||
renderSearchEverywhereResults([]);
|
||
}
|
||
updateStatus();
|
||
if (reindexedSources.size) {
|
||
showToast(t("search_everywhere_reindexed") || "Index refreshed.", 'success');
|
||
}
|
||
}
|
||
|
||
async function runSearchEverywhere(term) {
|
||
if (!term || !term.trim()) {
|
||
showToast(t("enter_search_term") || "Enter a search term.", 'warning');
|
||
return;
|
||
}
|
||
ensureSearchEverywhereModal();
|
||
const runId = ++searchEverywhereRunId;
|
||
const { statusEl, listEl } = ensureSearchEverywhereResultBlocks();
|
||
if (statusEl) {
|
||
statusEl.textContent = `${t("loading") || "Loading..."}`;
|
||
}
|
||
if (listEl) {
|
||
listEl.innerHTML = '';
|
||
}
|
||
|
||
const limitVal = searchEverywhereLimitEl ? Number(searchEverywhereLimitEl.value || 50) : 50;
|
||
const limit = Math.max(1, Math.min(200, limitVal || 50));
|
||
let sourceId = searchEverywhereSourceEl ? String(searchEverywhereSourceEl.value || '').trim() : '';
|
||
if (sourceId === 'all') {
|
||
const sources = getSearchEverywhereSources();
|
||
if (sources.length > 1) {
|
||
await runSearchEverywhereAcrossSources(term, limit, sources, runId);
|
||
return;
|
||
}
|
||
if (sources.length === 1) {
|
||
sourceId = String(sources[0]?.id || '').trim();
|
||
}
|
||
}
|
||
const sourceParam = sourceId ? `&sourceId=${encodeURIComponent(sourceId)}` : '';
|
||
|
||
try {
|
||
const res = await fetch(withBase(`/api/pro/search/query.php?q=${encodeURIComponent(term)}&limit=${limit}${sourceParam}`), {
|
||
credentials: 'include',
|
||
});
|
||
const data = await res.json();
|
||
if (runId !== searchEverywhereRunId) return;
|
||
if (!res.ok || !data.ok) {
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
renderSearchEverywhereResults(data.items || []);
|
||
if (statusEl) statusEl.textContent = '';
|
||
if (data.reindexed) {
|
||
showToast(t("search_everywhere_reindexed") || "Index refreshed.", 'success');
|
||
}
|
||
} catch (e) {
|
||
if (runId !== searchEverywhereRunId) return;
|
||
console.error('Search everywhere error', e);
|
||
if (searchEverywhereResultsEl) {
|
||
searchEverywhereResultsEl.innerHTML = `<div class="text-danger" style="font-size:12px;">${t("search_everywhere_error") || "Search failed."}</div>`;
|
||
}
|
||
if (statusEl) statusEl.textContent = '';
|
||
showToast(t("search_everywhere_error") || "Search failed.", 'error');
|
||
}
|
||
}
|
||
|
||
async function navigateToSearchResult(folder, name, sourceId) {
|
||
if (!folder) return;
|
||
closeSearchEverywhereModal();
|
||
const dest = decodeHtmlEntities(folder || 'root') || 'root';
|
||
const targetName = decodeHtmlEntities(name || '');
|
||
const targetSourceId = String(sourceId || '').trim();
|
||
const activeSourceId = getGlobalActiveSourceId();
|
||
|
||
if (targetSourceId && targetSourceId !== activeSourceId) {
|
||
let applied = false;
|
||
if (typeof window.__frApplyActiveSource === 'function') {
|
||
try {
|
||
applied = await window.__frApplyActiveSource(targetSourceId, { skipEvent: true, origin: 'search' });
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
if (!applied) {
|
||
const sel = document.getElementById('sourceSelector');
|
||
if (sel) sel.value = targetSourceId;
|
||
try { localStorage.setItem('fr_active_source', targetSourceId); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
const activePane = normalizePaneKey(window.activePane);
|
||
resetPagingForFolderNavigation(activePane);
|
||
savePaneState(activePane, {
|
||
sourceId: targetSourceId,
|
||
currentFolderCaps: null,
|
||
selectedFolderCaps: null
|
||
});
|
||
refreshSourceBadges();
|
||
try { resetFolderTreeCaches(); } catch (e) { /* ignore */ }
|
||
__frTreeSourceId = targetSourceId;
|
||
|
||
pendingSearchSelection = targetName ? { folder: dest, name: targetName } : null;
|
||
try { await loadFolderTree(dest); } catch (e) { /* ignore */ }
|
||
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 80);
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 220);
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 500);
|
||
return;
|
||
}
|
||
|
||
pendingSearchSelection = targetName ? { folder: dest, name: targetName } : null;
|
||
setCurrentFolderContext(dest, { resetPage: true });
|
||
|
||
// Refresh tree + strip selections to match the jumped folder
|
||
try {
|
||
await expandTreePathAsync(dest, { force: true, includeLeaf: true, persist: false });
|
||
document.querySelectorAll(".folder-option.selected")
|
||
.forEach(o => o.classList.remove("selected"));
|
||
const treeNode = document.querySelector(`.folder-option[data-folder="${CSS.escape(dest)}"]`);
|
||
if (treeNode) treeNode.classList.add("selected");
|
||
} catch (e) { /* best effort */ }
|
||
|
||
try {
|
||
const strip = document.getElementById("folderStripContainer");
|
||
if (strip) {
|
||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
||
const stripItem = strip.querySelector(`.folder-item[data-folder="${CSS.escape(dest)}"]`);
|
||
if (stripItem) stripItem.classList.add("selected");
|
||
}
|
||
} catch (e) { /* best effort */ }
|
||
|
||
await loadFileList(dest);
|
||
// Extra safety: attempt highlight again after render settles
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 80);
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 220);
|
||
setTimeout(() => maybeHighlightSearchedFile(dest), 500);
|
||
}
|
||
|
||
export async function navigateToLinkedFile(folder, name, sourceId) {
|
||
await navigateToSearchResult(folder, name, sourceId);
|
||
}
|
||
|
||
function bindFolderToolbarActions() {
|
||
const map = [
|
||
{ id: "folderMoveInlineBtn", handler: (folder) => openMoveFolderUI(folder) },
|
||
{ id: "folderRenameInlineBtn", handler: () => openRenameFolderModal() },
|
||
{ id: "folderColorInlineBtn", handler: (folder) => openColorFolderModal(folder) },
|
||
{ id: "folderEncryptInlineBtn", handler: (folder) => startFolderCryptoJobFlow(folder, 'encrypt') },
|
||
{ id: "folderDecryptInlineBtn", handler: (folder) => startFolderCryptoJobFlow(folder, 'decrypt') },
|
||
{ id: "folderShareInlineBtn", handler: (folder) => openFolderShareModal(folder) },
|
||
{ id: "folderDeleteInlineBtn", handler: () => openDeleteFolderModal() }
|
||
];
|
||
|
||
const selectedOrToast = () => {
|
||
const folder = getSelectedFolderPath();
|
||
if (!folder) {
|
||
showToast(t("select_folder") || "Select a folder first.", 'warning');
|
||
return null;
|
||
}
|
||
return folder;
|
||
};
|
||
|
||
map.forEach(({ id, handler }) => {
|
||
const btn = document.getElementById(id);
|
||
if (!btn || btn.dataset.bound === "1") return;
|
||
btn.dataset.bound = "1";
|
||
btn.addEventListener("click", () => {
|
||
const folder = selectedOrToast();
|
||
if (!folder) return;
|
||
setCurrentFolderContext(folder);
|
||
handler(folder);
|
||
});
|
||
});
|
||
}
|
||
|
||
function applyGalleryColumns(cols) {
|
||
const clamped = clampGalleryColumns(cols);
|
||
window.galleryColumns = clamped;
|
||
localStorage.setItem("galleryColumns", clamped);
|
||
|
||
const grid = document.querySelector(".gallery-container");
|
||
if (grid) {
|
||
grid.style.gridTemplateColumns = `repeat(${clamped},minmax(0,1fr))`;
|
||
}
|
||
document.querySelectorAll(".gallery-thumbnail")
|
||
.forEach(img => img.style.maxHeight = getMaxImageHeight() + "px");
|
||
|
||
const val = document.getElementById("galleryColumnsValue");
|
||
if (val) val.textContent = clamped;
|
||
|
||
return clamped;
|
||
}
|
||
|
||
let viewOptionsPopover = null;
|
||
let viewOptionsAnchor = null;
|
||
let viewOptionsBound = false;
|
||
|
||
function hideViewOptionsPopover() {
|
||
if (viewOptionsPopover) {
|
||
viewOptionsPopover.style.display = "none";
|
||
}
|
||
viewOptionsAnchor = null;
|
||
document.removeEventListener("click", viewOptionsOutside, true);
|
||
document.removeEventListener("keydown", viewOptionsEscape, true);
|
||
viewOptionsBound = false;
|
||
}
|
||
|
||
function viewOptionsOutside(ev) {
|
||
if (!viewOptionsPopover || viewOptionsPopover.style.display === "none") return;
|
||
const anchor = viewOptionsAnchor;
|
||
if (viewOptionsPopover.contains(ev.target)) return;
|
||
if (anchor && (ev.target === anchor || anchor.contains(ev.target))) return;
|
||
hideViewOptionsPopover();
|
||
}
|
||
|
||
function viewOptionsEscape(ev) {
|
||
if (ev.key === "Escape") hideViewOptionsPopover();
|
||
}
|
||
|
||
function ensureViewOptionsPopover() {
|
||
if (viewOptionsPopover) return viewOptionsPopover;
|
||
const el = document.createElement("div");
|
||
el.id = "viewOptionsPopover";
|
||
el.className = "filr-popover";
|
||
el.style.display = "none";
|
||
document.body.appendChild(el);
|
||
viewOptionsPopover = el;
|
||
return el;
|
||
}
|
||
|
||
function buildRowHeightControls(host) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "vo-control";
|
||
|
||
const label = document.createElement("div");
|
||
label.className = "vo-label";
|
||
label.textContent = t("row_height") || "Row height";
|
||
|
||
const sliderRow = document.createElement("div");
|
||
sliderRow.className = "vo-slider-row";
|
||
|
||
const slider = document.createElement("input");
|
||
slider.type = "range";
|
||
slider.min = "20";
|
||
slider.max = "60";
|
||
slider.value = clampRowHeight(localStorage.getItem("rowHeight") || 44);
|
||
slider.id = "rowHeightSlider";
|
||
|
||
const value = document.createElement("span");
|
||
value.id = "rowHeightValue";
|
||
value.className = "vo-value";
|
||
value.textContent = `${slider.value}px`;
|
||
|
||
slider.addEventListener("input", () => {
|
||
const h = applyRowHeight(slider.value);
|
||
value.textContent = `${h}px`;
|
||
});
|
||
|
||
sliderRow.appendChild(slider);
|
||
sliderRow.appendChild(value);
|
||
|
||
wrap.appendChild(label);
|
||
wrap.appendChild(sliderRow);
|
||
host.appendChild(wrap);
|
||
}
|
||
|
||
function buildGalleryControls(host) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "vo-control";
|
||
|
||
const label = document.createElement("div");
|
||
label.className = "vo-label";
|
||
label.textContent = t("columns") || "Columns";
|
||
|
||
const sliderRow = document.createElement("div");
|
||
sliderRow.className = "vo-slider-row";
|
||
|
||
const slider = document.createElement("input");
|
||
slider.type = "range";
|
||
slider.min = "1";
|
||
slider.max = String(getGalleryMaxColumns());
|
||
slider.value = clampGalleryColumns(localStorage.getItem("galleryColumns") || window.galleryColumns || 3);
|
||
slider.id = "galleryColumnsSlider";
|
||
|
||
const value = document.createElement("span");
|
||
value.id = "galleryColumnsValue";
|
||
value.className = "vo-value";
|
||
value.textContent = slider.value;
|
||
|
||
slider.addEventListener("input", () => {
|
||
const cols = applyGalleryColumns(slider.value);
|
||
value.textContent = cols;
|
||
});
|
||
|
||
sliderRow.appendChild(slider);
|
||
sliderRow.appendChild(value);
|
||
|
||
wrap.appendChild(label);
|
||
wrap.appendChild(sliderRow);
|
||
host.appendChild(wrap);
|
||
}
|
||
|
||
function currentZoomPercent() {
|
||
try {
|
||
if (window.fileriseZoom && typeof window.fileriseZoom.currentPercent === "function") {
|
||
return window.fileriseZoom.currentPercent();
|
||
}
|
||
} catch (e) {}
|
||
const css = getComputedStyle(document.documentElement).getPropertyValue('--app-zoom') || "1";
|
||
const n = parseFloat(css);
|
||
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : 100;
|
||
}
|
||
|
||
function buildZoomControls(host) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "vo-control";
|
||
|
||
const label = document.createElement("div");
|
||
label.className = "vo-label";
|
||
label.textContent = t("zoom") || "Zoom";
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "vo-slider-row";
|
||
|
||
const slider = document.createElement("input");
|
||
slider.type = "range";
|
||
slider.min = "60";
|
||
slider.max = "140";
|
||
slider.step = "5";
|
||
slider.value = currentZoomPercent();
|
||
slider.id = "zoomPercentSlider";
|
||
|
||
const value = document.createElement("span");
|
||
value.id = "zoomPercentValue";
|
||
value.className = "vo-value";
|
||
value.textContent = `${slider.value}%`;
|
||
|
||
const applyZoom = (pct) => {
|
||
const api = window.fileriseZoom;
|
||
if (api && typeof api.setPercent === "function") {
|
||
api.setPercent(pct);
|
||
} else {
|
||
document.documentElement.style.setProperty('--app-zoom', String(pct / 100));
|
||
}
|
||
value.textContent = `${pct}%`;
|
||
slider.value = String(pct);
|
||
};
|
||
|
||
slider.addEventListener("input", () => {
|
||
applyZoom(parseInt(slider.value, 10));
|
||
});
|
||
|
||
const btnRow = document.createElement("div");
|
||
btnRow.className = "vo-zoom-btns";
|
||
|
||
const mkBtn = (icon, title, handler) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "btn btn-sm btn-light vo-zoom-btn";
|
||
b.title = title;
|
||
b.innerHTML = `<span class="material-icons">${icon}</span>`;
|
||
b.addEventListener("click", handler);
|
||
return b;
|
||
};
|
||
|
||
btnRow.appendChild(mkBtn("remove", t("zoom_out") || "Zoom out", () => {
|
||
const api = window.fileriseZoom;
|
||
const next = api && api.out ? api.out() : currentZoomPercent() - 5;
|
||
applyZoom(next);
|
||
}));
|
||
btnRow.appendChild(mkBtn("add", t("zoom_in") || "Zoom in", () => {
|
||
const api = window.fileriseZoom;
|
||
const next = api && api.in ? api.in() : currentZoomPercent() + 5;
|
||
applyZoom(next);
|
||
}));
|
||
btnRow.appendChild(mkBtn("refresh", t("reset_zoom") || "Reset", () => {
|
||
const api = window.fileriseZoom;
|
||
const next = api && api.reset ? api.reset() : 100;
|
||
applyZoom(next);
|
||
}));
|
||
|
||
row.appendChild(slider);
|
||
row.appendChild(value);
|
||
|
||
wrap.appendChild(label);
|
||
wrap.appendChild(row);
|
||
wrap.appendChild(btnRow);
|
||
host.appendChild(wrap);
|
||
}
|
||
|
||
function buildDualPaneToggle(host) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "vo-control";
|
||
|
||
const row = document.createElement("label");
|
||
row.className = "vo-toggle-row";
|
||
|
||
const text = document.createElement("span");
|
||
text.className = "vo-toggle-text";
|
||
text.textContent = t("dual_pane_mode") || "Dual-pane mode";
|
||
|
||
const toggle = document.createElement("input");
|
||
toggle.type = "checkbox";
|
||
toggle.className = "vo-toggle-input";
|
||
toggle.id = "voDualPaneToggle";
|
||
toggle.checked = localStorage.getItem("dualPaneMode") === "true";
|
||
toggle.setAttribute("aria-label", text.textContent);
|
||
|
||
const applyDualPane = (enabled) => {
|
||
localStorage.setItem("dualPaneMode", enabled ? "true" : "false");
|
||
window.dualPaneEnabled = enabled;
|
||
if (typeof window.applyDualPaneMode === "function") {
|
||
window.applyDualPaneMode(enabled);
|
||
} else {
|
||
window.__frDualPanePending = enabled;
|
||
}
|
||
const panelToggle = document.getElementById("dualPaneMode");
|
||
if (panelToggle) {
|
||
panelToggle.checked = enabled;
|
||
}
|
||
};
|
||
|
||
toggle.addEventListener("change", () => {
|
||
applyDualPane(toggle.checked);
|
||
});
|
||
|
||
row.appendChild(text);
|
||
row.appendChild(toggle);
|
||
wrap.appendChild(row);
|
||
host.appendChild(wrap);
|
||
}
|
||
|
||
function positionPopover(pop, anchor) {
|
||
if (!anchor) return;
|
||
const rect = anchor.getBoundingClientRect();
|
||
pop.style.display = "block";
|
||
pop.style.visibility = "hidden";
|
||
|
||
const { width: pw, height: ph } = pop.getBoundingClientRect();
|
||
let left = rect.left + rect.width - pw;
|
||
let top = rect.bottom + 8;
|
||
const padding = 8;
|
||
|
||
left = Math.min(Math.max(padding, left), window.innerWidth - pw - padding);
|
||
top = Math.min(Math.max(padding, top), window.innerHeight - ph - padding);
|
||
|
||
pop.style.left = `${left}px`;
|
||
pop.style.top = `${top}px`;
|
||
pop.style.visibility = "visible";
|
||
}
|
||
|
||
function showViewOptionsPopover(triggerBtn) {
|
||
const pop = ensureViewOptionsPopover();
|
||
if (pop.style.display === "block" && viewOptionsAnchor === triggerBtn) {
|
||
hideViewOptionsPopover();
|
||
return;
|
||
}
|
||
|
||
pop.innerHTML = "";
|
||
viewOptionsAnchor = triggerBtn || null;
|
||
|
||
const title = document.createElement("div");
|
||
title.className = "vo-title";
|
||
title.textContent = t("view_options") || "View options";
|
||
pop.appendChild(title);
|
||
|
||
if (window.viewMode === "gallery") {
|
||
buildGalleryControls(pop);
|
||
} else {
|
||
buildRowHeightControls(pop);
|
||
}
|
||
|
||
// separator then zoom controls
|
||
const sep = document.createElement("div");
|
||
sep.className = "vo-separator";
|
||
pop.appendChild(sep);
|
||
buildZoomControls(pop);
|
||
|
||
const sep2 = document.createElement("div");
|
||
sep2.className = "vo-separator";
|
||
pop.appendChild(sep2);
|
||
buildDualPaneToggle(pop);
|
||
|
||
positionPopover(pop, triggerBtn);
|
||
pop.style.display = "block";
|
||
|
||
if (!viewOptionsBound) {
|
||
document.addEventListener("click", viewOptionsOutside, true);
|
||
document.addEventListener("keydown", viewOptionsEscape, true);
|
||
viewOptionsBound = true;
|
||
}
|
||
}
|
||
|
||
function bindViewOptionsButton() {
|
||
const btn = document.getElementById("viewOptionsBtn");
|
||
if (!btn || btn.dataset.bound === "1") return;
|
||
btn.dataset.bound = "1";
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
showViewOptionsPopover(btn);
|
||
});
|
||
}
|
||
|
||
export function formatFolderName(folder, sourceId = '') {
|
||
if (folder === "root") return getRootLabel(sourceId);
|
||
return folder
|
||
.replace(/[_-]+/g, " ")
|
||
.replace(/\b\w/g, char => char.toUpperCase());
|
||
}
|
||
|
||
// Expose inline DOM helpers.
|
||
window.toggleRowSelection = toggleRowSelection;
|
||
window.updateRowHighlight = updateRowHighlight;
|
||
|
||
export async function loadFileList(folderParam, options = {}) {
|
||
clearInlineRenameState({ restore: false });
|
||
await initOnlyOfficeCaps();
|
||
hideHoverPreview();
|
||
const pane = normalizePaneKey(options.pane || window.activePane);
|
||
const reqId = ++__fileListReqSeq[pane]; // latest call wins
|
||
const folderOnly =
|
||
window.userFolderOnly === true ||
|
||
localStorage.getItem("folderOnly") === "true";
|
||
const username = (localStorage.getItem("username") || "").trim();
|
||
let paneSourceId = getPaneSourceId(pane);
|
||
const paneState = getPaneState(pane);
|
||
const paneFolder = (paneState && paneState.sourceId === paneSourceId && typeof paneState.currentFolder === 'string')
|
||
? paneState.currentFolder.trim()
|
||
: readStoredPaneFolder(pane, paneSourceId);
|
||
const lastOpenedFolder = getLastOpenedFolder(paneSourceId);
|
||
const { skipFallback } = options || {};
|
||
const sourceParam = paneSourceId ? `&sourceId=${encodeURIComponent(paneSourceId)}` : '';
|
||
const isFtp = isFtpSource(paneSourceId);
|
||
const shouldFetchFolders = window.showFoldersInList !== false || window.showInlineFolders !== false;
|
||
const includeContent = window.advancedSearchEnabled === true || options.includeContent === true;
|
||
const useServerPaging = shouldUseServerFilePagingForRequest(options);
|
||
let requestedCursor = (options && Object.prototype.hasOwnProperty.call(options, 'cursor'))
|
||
? String(options.cursor || '')
|
||
: null;
|
||
const hadExplicitFolderParam = folderParam !== undefined && folderParam !== null && String(folderParam).trim() !== '';
|
||
const prevPaneFolder = (paneState && typeof paneState.currentFolder === 'string')
|
||
? paneState.currentFolder.trim()
|
||
: '';
|
||
|
||
let folder = folderParam || paneFolder || lastOpenedFolder || (paneSourceId ? "root" : (window.currentFolder || "root"));
|
||
const isActivePane = pane === normalizePaneKey(window.activePane);
|
||
if (isActivePane && folder) {
|
||
window.currentFolder = folder;
|
||
updateBreadcrumbTitle(folder);
|
||
}
|
||
if (folderOnly && (!folder || folder === "root") && username) {
|
||
folder = username;
|
||
window.currentFolder = folder;
|
||
setLastOpenedFolder(folder, paneSourceId);
|
||
updateBreadcrumbTitle(folder);
|
||
}
|
||
const prevFolderKey = normalizeFolderPath(prevPaneFolder || paneFolder || '');
|
||
const nextFolderKey = normalizeFolderPath(folder || '');
|
||
const folderChangedByExplicitLoad = hadExplicitFolderParam && prevFolderKey !== nextFolderKey;
|
||
if (folderChangedByExplicitLoad) {
|
||
resetPagingForFolderNavigation(pane);
|
||
// Ignore any stale cursor when changing folders through a direct load call.
|
||
requestedCursor = null;
|
||
}
|
||
savePaneState(pane, { currentFolder: folder });
|
||
const subfoldersMatch =
|
||
String(window.currentSubfoldersSourceId || '') === String(paneSourceId || '') &&
|
||
String(window.currentSubfoldersFolder || '') === String(folder || '');
|
||
if (!subfoldersMatch) {
|
||
window.currentSubfolders = [];
|
||
window.currentSubfoldersSourceId = String(paneSourceId || '');
|
||
window.currentSubfoldersFolder = String(folder || '');
|
||
savePaneState(pane, {
|
||
currentSubfolders: [],
|
||
currentSubfoldersSourceId: window.currentSubfoldersSourceId,
|
||
currentSubfoldersFolder: window.currentSubfoldersFolder
|
||
});
|
||
const strip = document.getElementById('folderStripContainer');
|
||
if (strip) {
|
||
strip.innerHTML = '';
|
||
strip.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
const fileListContainer = document.getElementById("fileList");
|
||
if (fileListContainer) {
|
||
fileListContainer.classList.remove("file-list-secondary-empty");
|
||
}
|
||
const actionsContainer = document.getElementById("fileListActions");
|
||
window.selectedFolderCaps = null;
|
||
savePaneState(pane, { selectedFolderCaps: null });
|
||
refreshCurrentFolderCaps(folder, paneSourceId, pane);
|
||
if (!useServerPaging) {
|
||
setPaneFileListPaging(pane, null);
|
||
}
|
||
|
||
// 1) show loader (only this request is allowed to render)
|
||
fileListContainer.style.visibility = "visible";
|
||
fileListContainer.setAttribute('aria-busy', 'true');
|
||
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
|
||
const slowToastTimer = setTimeout(() => {
|
||
if (reqId === __fileListReqSeq[pane]) {
|
||
const now = Date.now();
|
||
if ((now - _lastSlowLoadToastAt) >= SLOW_LOAD_TOAST_COOLDOWN_MS) {
|
||
_lastSlowLoadToastAt = now;
|
||
showToast(t('loading_slow') || "Still loading... remote sources can take a while.", 'info');
|
||
}
|
||
}
|
||
}, SLOW_LOAD_TOAST_DELAY_MS);
|
||
|
||
try {
|
||
// Kick off both in parallel, but render as soon as FILES are ready
|
||
const recursiveParam = folderOnly ? 0 : 1;
|
||
const buildFileListUrl = (targetFolder, recursiveValue, cursorValue = null, forceNoPaging = false) => {
|
||
const params = new URLSearchParams();
|
||
params.set('folder', targetFolder);
|
||
params.set('recursive', String(recursiveValue));
|
||
if (paneSourceId) {
|
||
params.set('sourceId', paneSourceId);
|
||
}
|
||
if (includeContent) {
|
||
params.set('includeContent', '1');
|
||
}
|
||
if (!forceNoPaging && useServerPaging) {
|
||
const pageSize = getFileListCursorPageSize();
|
||
const sortColumn = String(sortOrder?.column || 'modified');
|
||
const sortDir = (sortOrder && sortOrder.ascending === true) ? 'asc' : 'desc';
|
||
const allowedSort = new Set(['name', 'modified', 'uploaded', 'size', 'uploader']);
|
||
params.set('pageSize', String(pageSize));
|
||
params.set('sortBy', allowedSort.has(sortColumn) ? sortColumn : 'modified');
|
||
params.set('sortDir', sortDir);
|
||
const cursorText = (cursorValue == null) ? '' : String(cursorValue).trim();
|
||
if (cursorText !== '') {
|
||
params.set('cursor', cursorText);
|
||
}
|
||
}
|
||
params.set('t', String(Date.now()));
|
||
return withBase(`/api/file/getFileList.php?${params.toString()}`);
|
||
};
|
||
|
||
const startCursor = useServerPaging
|
||
? resolveServerPagingCursorForLoad({
|
||
pane,
|
||
folder,
|
||
sourceId: paneSourceId,
|
||
requestedCursor
|
||
})
|
||
: null;
|
||
const filesPromise = fetch(buildFileListUrl(folder, recursiveParam, startCursor), { credentials: 'include' });
|
||
let foldersPromise = null;
|
||
if (!isFtp && shouldFetchFolders) {
|
||
foldersPromise = fetch(
|
||
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0${sourceParam}`),
|
||
{ credentials: 'include' }
|
||
);
|
||
}
|
||
|
||
// ----- FILES FIRST -----
|
||
let filesRes = await filesPromise;
|
||
|
||
// If the first attempt is forbidden (common for folder-only users or wrong folder),
|
||
// retry smartly:
|
||
// - first, drop recursion
|
||
// - if still forbidden and we have a username, try that folder once
|
||
if (filesRes.status === 403 && folder !== "root") {
|
||
try {
|
||
filesRes = await fetch(
|
||
buildFileListUrl(folder, 0, startCursor),
|
||
{ credentials: "include" }
|
||
);
|
||
} catch (e) { /* ignore and fall through */ }
|
||
}
|
||
if (filesRes.status === 403 && username && folder !== username) {
|
||
try {
|
||
const alt = await fetch(
|
||
buildFileListUrl(username, 0, useServerPaging ? '' : null),
|
||
{ credentials: "include" }
|
||
);
|
||
if (alt.ok) {
|
||
// switch context to the user folder
|
||
filesRes = alt;
|
||
folder = username;
|
||
window.currentFolder = folder;
|
||
setLastOpenedFolder(folder, paneSourceId);
|
||
updateBreadcrumbTitle(folder);
|
||
// remember that this is a folder-only session
|
||
window.userFolderOnly = true;
|
||
try { localStorage.setItem("folderOnly", "true"); } catch (e) { }
|
||
refreshCurrentFolderCaps(folder, paneSourceId, pane);
|
||
// refresh folders promise for the new folder context
|
||
if (!isFtp && shouldFetchFolders) {
|
||
foldersPromise = fetch(
|
||
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0${sourceParam}`),
|
||
{ credentials: 'include' }
|
||
);
|
||
}
|
||
}
|
||
} catch (e) { /* ignore; will fall back to the normal 403 handling */ }
|
||
}
|
||
|
||
if (filesRes.status === 401) {
|
||
// session expired — bounce to logout
|
||
window.location.href = withBase("/api/auth/logout.php");
|
||
throw new Error("Unauthorized");
|
||
}
|
||
if (filesRes.status === 403) {
|
||
if (!skipFallback) {
|
||
const fallback = await findBestAccessibleFolder({ lastOpenedFolder, sourceId: paneSourceId });
|
||
if (fallback && fallback !== folder) {
|
||
setCurrentFolderContext(fallback, { pane, resetPage: true });
|
||
return await loadFileList(fallback, { pane, skipFallback: true, forceLegacy: options.forceLegacy === true });
|
||
}
|
||
}
|
||
// For folder-only users, treat 403 as "empty list" instead of hard error.
|
||
if (folderOnly) {
|
||
fileListContainer.innerHTML = `
|
||
<div class="empty-state">
|
||
${t("no_files_found") || "No files found."}
|
||
</div>`;
|
||
const summaryElem = document.getElementById("fileSummary");
|
||
if (summaryElem) summaryElem.style.display = "none";
|
||
const strip = document.getElementById("folderStripContainer");
|
||
if (strip) strip.style.display = "none";
|
||
updateFileActionButtons();
|
||
fileListContainer.style.visibility = "visible";
|
||
return [];
|
||
}
|
||
|
||
// forbidden — friendly message, keep UI responsive
|
||
fileListContainer.innerHTML = `
|
||
<div class="empty-state">
|
||
${t("no_access_to_resource") || "You don't have access to this folder."}
|
||
</div>`;
|
||
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
|
||
return [];
|
||
}
|
||
|
||
const data = await safeJson(filesRes);
|
||
if (data.error) {
|
||
throw new Error(typeof data.error === 'string' ? data.error : 'Server returned an error.');
|
||
}
|
||
const responseSourceId = String(data.sourceId || '').trim();
|
||
if (!paneSourceId && responseSourceId) {
|
||
paneSourceId = responseSourceId;
|
||
savePaneState(pane, { sourceId: responseSourceId });
|
||
}
|
||
const pagingRaw = (data && typeof data.paging === 'object' && data.paging) ? data.paging : null;
|
||
if (useServerPaging && pagingRaw) {
|
||
const limit = Number.isFinite(Number(pagingRaw.limit))
|
||
? Math.max(FILE_LIST_CURSOR_PAGE_SIZE_MIN, Math.min(FILE_LIST_CURSOR_PAGE_SIZE_MAX, Number(pagingRaw.limit)))
|
||
: getFileListCursorPageSize();
|
||
const total = Number.isFinite(Number(pagingRaw.total)) ? Math.max(0, Number(pagingRaw.total)) : 0;
|
||
const totalPagesRaw = Number.isFinite(Number(pagingRaw.totalPages)) ? Number(pagingRaw.totalPages) : 0;
|
||
const totalPages = Math.max(1, totalPagesRaw || Math.ceil(total / limit) || 1);
|
||
const pageRaw = Number.isFinite(Number(pagingRaw.page)) ? Number(pagingRaw.page) : 1;
|
||
const page = Math.max(1, Math.min(totalPages, pageRaw || 1));
|
||
const nextCursor = (pagingRaw.nextCursor == null) ? null : String(pagingRaw.nextCursor);
|
||
const prevCursor = (pagingRaw.prevCursor == null) ? null : String(pagingRaw.prevCursor);
|
||
const cursor = String(pagingRaw.cursor == null ? '' : pagingRaw.cursor);
|
||
setPaneFileListPaging(pane, {
|
||
enabled: true,
|
||
mode: 'cursor',
|
||
cursor,
|
||
nextCursor,
|
||
prevCursor,
|
||
hasMore: !!pagingRaw.hasMore,
|
||
limit,
|
||
total,
|
||
totalPages,
|
||
page,
|
||
sortBy: String(pagingRaw.sortBy || sortOrder?.column || 'modified'),
|
||
sortDir: String(pagingRaw.sortDir || ((sortOrder && sortOrder.ascending === true) ? 'asc' : 'desc'))
|
||
});
|
||
} else {
|
||
setPaneFileListPaging(pane, null);
|
||
}
|
||
|
||
// If another loadFileList ran after this one, bail before touching the DOM
|
||
if (reqId !== __fileListReqSeq[pane]) return [];
|
||
|
||
// 3) clear loader
|
||
fileListContainer.innerHTML = "";
|
||
|
||
// 4) handle “no files” case
|
||
if (!data.files || Object.keys(data.files).length === 0) {
|
||
if (reqId !== __fileListReqSeq[pane]) return [];
|
||
fileListContainer.innerHTML = `
|
||
<div class="empty-state">
|
||
${t("no_files_found")}
|
||
<div style="margin-top:6px;font-size:.9em;color:#777">
|
||
${t("no_folder_access_yet") || "No folder access has been assigned to your account yet."}
|
||
</div>
|
||
</div>`;
|
||
|
||
const summaryElem = document.getElementById("fileSummary");
|
||
if (summaryElem) summaryElem.style.display = "none";
|
||
|
||
const strip = document.getElementById("folderStripContainer");
|
||
if (strip) strip.style.display = "none";
|
||
|
||
updateFileActionButtons();
|
||
fileListContainer.style.visibility = "visible";
|
||
// We still try to populate the folder strip below
|
||
}
|
||
|
||
// 5) normalize files array
|
||
if (!Array.isArray(data.files)) {
|
||
data.files = Object.entries(data.files).map(([name, meta]) => {
|
||
meta.name = name;
|
||
return meta;
|
||
});
|
||
}
|
||
|
||
data.files = data.files.map(f => {
|
||
f.fullName = (f.path || f.name).trim().toLowerCase();
|
||
|
||
let bytes = Number.isFinite(f.sizeBytes)
|
||
? f.sizeBytes
|
||
: parseSizeToBytes(String(f.size || ""));
|
||
|
||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||
bytes = null;
|
||
}
|
||
|
||
f.sizeBytes = bytes;
|
||
|
||
// New: normalize display size and create a stable cache key
|
||
if (bytes != null) {
|
||
f.size = formatSize(bytes);
|
||
}
|
||
|
||
const cacheKey =
|
||
(f.modified && String(f.modified)) ||
|
||
(f.uploaded && String(f.uploaded)) ||
|
||
(bytes != null ? String(bytes) : "") ||
|
||
f.name;
|
||
|
||
f.cacheKey = cacheKey;
|
||
f.folder = folder;
|
||
const sid = String(f.sourceId || paneSourceId || '').trim();
|
||
if (sid) {
|
||
f.sourceId = sid;
|
||
}
|
||
|
||
// For editing: if size is unknown, assume it's OK and let the editor enforce limits.
|
||
const safeForEdit = (bytes == null) || (bytes <= MAX_EDIT_BYTES);
|
||
f.editable = canEditFile(f.name) && safeForEdit;
|
||
|
||
return f;
|
||
});
|
||
fileData = data.files;
|
||
savePaneState(pane, { fileData: fileData, currentFolder: folder, hasLoaded: true });
|
||
|
||
if (reqId !== __fileListReqSeq[pane]) return [];
|
||
|
||
// 6) inject summary + slider
|
||
if (actionsContainer) {
|
||
// a) summary
|
||
let summaryElem = document.getElementById("fileSummary");
|
||
if (!summaryElem) {
|
||
summaryElem = document.createElement("div");
|
||
summaryElem.id = "fileSummary";
|
||
summaryElem.className = "fr-summary-pill";
|
||
summaryElem.setAttribute("role", "status");
|
||
summaryElem.setAttribute("aria-live", "polite");
|
||
actionsContainer.appendChild(summaryElem);
|
||
}
|
||
summaryElem.classList.add("fr-summary-pill");
|
||
summaryElem.style.display = "flex";
|
||
const fallbackStats = getLocalSummaryStats();
|
||
summaryElem.innerHTML = buildFolderSummaryHTML(null, fallbackStats);
|
||
summaryElem.dataset.folder = folder;
|
||
summaryElem.dataset.truncated = "0";
|
||
|
||
fetchFolderSummaryStats(folder, undefined, paneSourceId).then(stats => {
|
||
if (!stats || summaryElem.dataset.folder !== folder) return;
|
||
summaryElem.innerHTML = buildFolderSummaryHTML(stats, fallbackStats);
|
||
summaryElem.dataset.truncated = stats.truncated ? "1" : "0";
|
||
if (stats.truncated) {
|
||
summaryElem.title = "Totals capped for very large folders.";
|
||
} else {
|
||
summaryElem.removeAttribute("title");
|
||
}
|
||
});
|
||
}
|
||
|
||
// 7) render files
|
||
if (reqId !== __fileListReqSeq[pane]) return [];
|
||
|
||
if (window.viewMode === "gallery") {
|
||
renderGalleryView(folder);
|
||
} else {
|
||
renderFileTable(folder);
|
||
}
|
||
updateFileActionButtons();
|
||
fileListContainer.style.visibility = "visible";
|
||
|
||
// Highlight a search-hit file if requested
|
||
maybeHighlightSearchedFile(folder);
|
||
|
||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||
try {
|
||
if (!foldersPromise && isFtp && shouldFetchFolders) {
|
||
foldersPromise = new Promise(resolve => {
|
||
setTimeout(() => {
|
||
resolve(fetch(
|
||
withBase(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}&counts=0${sourceParam}`),
|
||
{ credentials: 'include' }
|
||
));
|
||
}, 600);
|
||
});
|
||
}
|
||
|
||
if (foldersPromise) {
|
||
const foldersRes = await foldersPromise;
|
||
// If folders API forbids, just skip the strip; keep file rows rendered
|
||
if (foldersRes.status === 403) {
|
||
const strip = document.getElementById("folderStripContainer");
|
||
if (strip) strip.style.display = "none";
|
||
return data.files;
|
||
}
|
||
|
||
const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on strip issues
|
||
if (reqId !== __fileListReqSeq[pane]) return data.files;
|
||
|
||
// --- build ONLY the *direct* children of current folder ---
|
||
let subfolders = [];
|
||
const hidden = new Set(["profile_pics", "trash"]);
|
||
if (folder === "root") {
|
||
hidden.add("root");
|
||
}
|
||
if (Array.isArray(folderRaw)) {
|
||
const allPaths = folderRaw.map(item => item.folder ?? item);
|
||
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
|
||
subfolders = allPaths
|
||
.filter(p => {
|
||
if (folder === "root") return p.indexOf("/") === -1;
|
||
if (!p.startsWith(folder + "/")) return false;
|
||
return p.split("/").length === depth;
|
||
})
|
||
.map(p => ({ name: p.split("/").pop(), full: p }));
|
||
}
|
||
|
||
subfolders = subfolders.filter(sf => {
|
||
const lower = (sf.name || "").toLowerCase();
|
||
return !hidden.has(lower) && !lower.startsWith("resumable_");
|
||
});
|
||
|
||
// Expose for inline folder rows in table view
|
||
window.currentSubfolders = subfolders;
|
||
window.currentSubfoldersSourceId = String(paneSourceId || '');
|
||
window.currentSubfoldersFolder = String(folder || '');
|
||
savePaneState(pane, {
|
||
currentSubfolders: subfolders,
|
||
currentSubfoldersSourceId: window.currentSubfoldersSourceId,
|
||
currentSubfoldersFolder: window.currentSubfoldersFolder
|
||
});
|
||
|
||
let strip = document.getElementById("folderStripContainer");
|
||
if (!strip) {
|
||
strip = document.createElement("div");
|
||
strip.id = "folderStripContainer";
|
||
strip.className = "folder-strip-container";
|
||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||
}
|
||
|
||
// NEW: paged + responsive strip
|
||
renderFolderStripPaged(strip, subfolders, paneSourceId);
|
||
|
||
// Re-render table view once folders are known so they appear inline above files
|
||
if (window.viewMode === "table" && window.showInlineFolders !== false && reqId === __fileListReqSeq[pane]) {
|
||
renderFileTable(folder, fileListContainer, undefined, { preserveSelection: true });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// ignore folder errors; rows already rendered
|
||
}
|
||
|
||
setPaneHasContent(pane, true);
|
||
// Intentionally skip auto-restoring the inactive pane for lazy-load perf.
|
||
if (window.dualPaneEnabled) {
|
||
scheduleFolderIconRepair();
|
||
scheduleInactivePaneFolderIconRepair();
|
||
}
|
||
updateDualPaneTargetHint();
|
||
return data.files;
|
||
|
||
} catch (err) {
|
||
if (reqId !== __fileListReqSeq[pane]) return [];
|
||
const errMsg = String(err?.message || '').toLowerCase();
|
||
const missingFolder = /directory not found|folder does not exist|does not exist|not found/.test(errMsg);
|
||
if (missingFolder && !skipFallback) {
|
||
const fallback = getParentFolder(folder || 'root') || 'root';
|
||
if (fallback && fallback !== folder) {
|
||
setCurrentFolderContext(fallback, { pane, resetPage: true });
|
||
if (isActivePane) {
|
||
try { syncFolderTreeSelection(fallback); } catch (e) { /* ignore */ }
|
||
}
|
||
return await loadFileList(fallback, { pane, skipFallback: true, forceLegacy: options.forceLegacy === true });
|
||
}
|
||
}
|
||
console.error("Error loading file list:", err);
|
||
if (err.status === 403) {
|
||
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
|
||
const fileListContainer = document.getElementById("fileList");
|
||
if (fileListContainer) fileListContainer.textContent = t("no_access_to_resource") || "You don't have access to this folder.";
|
||
} else if (err.message !== "Unauthorized") {
|
||
const fileListContainer = document.getElementById("fileList");
|
||
if (fileListContainer) fileListContainer.textContent = "Error loading files.";
|
||
}
|
||
return [];
|
||
} finally {
|
||
if (reqId === __fileListReqSeq[pane]) {
|
||
clearTimeout(slowToastTimer);
|
||
fileListContainer.style.visibility = "visible";
|
||
fileListContainer.removeAttribute('aria-busy');
|
||
}
|
||
}
|
||
}
|
||
|
||
function makeInlineFolderDragImage(labelText) {
|
||
const isDark = document.body.classList.contains('dark-mode');
|
||
|
||
const textColor = isDark
|
||
? '#f1f3f4'
|
||
: 'var(--filr-text, #111827)';
|
||
|
||
const bgColor = isDark
|
||
? 'rgba(32,33,36,0.96)'
|
||
: 'var(--filr-bg-elevated, #ffffff)';
|
||
|
||
const borderColor = isDark
|
||
? 'rgba(255,255,255,0.14)'
|
||
: 'rgba(15,23,42,0.12)';
|
||
|
||
const wrap = document.createElement('div');
|
||
Object.assign(wrap.style, {
|
||
position: 'fixed',
|
||
top: '-9999px',
|
||
left: '-9999px',
|
||
zIndex: '99999',
|
||
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
|
||
padding: '7px 16px',
|
||
minHeight: '32px',
|
||
maxWidth: '420px',
|
||
whiteSpace: 'nowrap',
|
||
|
||
borderRadius: '999px',
|
||
overflow: 'hidden',
|
||
backgroundClip: 'padding-box',
|
||
|
||
background: bgColor,
|
||
color: textColor,
|
||
border: `1px solid ${borderColor}`,
|
||
boxShadow: '0 4px 18px rgba(0,0,0,0.18)',
|
||
|
||
fontSize: '14px',
|
||
lineHeight: '1.4',
|
||
fontWeight: '500',
|
||
|
||
pointerEvents: 'none'
|
||
});
|
||
|
||
const icon = document.createElement('span');
|
||
icon.className = 'material-icons';
|
||
icon.textContent = 'folder';
|
||
Object.assign(icon.style, {
|
||
fontSize: '20px',
|
||
lineHeight: '1',
|
||
flexShrink: '0',
|
||
color: textColor
|
||
});
|
||
|
||
const label = document.createElement('span');
|
||
const txt = String(labelText || '');
|
||
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||
Object.assign(label.style, {
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis'
|
||
});
|
||
|
||
wrap.appendChild(icon);
|
||
wrap.appendChild(label);
|
||
document.body.appendChild(wrap);
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function folderRowDragStartHandler(event, fullPath) {
|
||
try { cancelHoverPreview(); } catch (e) {}
|
||
|
||
if (!fullPath) return;
|
||
|
||
const srcParent = parentFolderOf(fullPath);
|
||
const paneSourceId = getPaneSourceIdForElement(event.currentTarget) || getActivePaneSourceId();
|
||
|
||
const payload = {
|
||
dragType: 'folder',
|
||
folder: fullPath,
|
||
sourceFolder: srcParent,
|
||
sourceId: paneSourceId
|
||
};
|
||
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||
event.dataTransfer.setData('text/plain', fullPath);
|
||
|
||
const label = fullPath.split('/').pop() || fullPath;
|
||
const ghost = makeInlineFolderDragImage(label);
|
||
event.dataTransfer.setDragImage(ghost, 10, 10);
|
||
setTimeout(() => {
|
||
try { document.body.removeChild(ghost); } catch (e) {}
|
||
}, 0);
|
||
}
|
||
|
||
function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
|
||
const table = fileListContent.querySelector('table.filr-table');
|
||
|
||
// Use the paged subfolders if provided, otherwise fall back to all
|
||
const subfolders = Array.isArray(pageSubfolders) && pageSubfolders.length
|
||
? pageSubfolders
|
||
: (Array.isArray(window.currentSubfolders) ? window.currentSubfolders : []);
|
||
|
||
if (!table || !subfolders.length) return;
|
||
|
||
const thead = table.tHead;
|
||
const tbody = table.tBodies && table.tBodies[0];
|
||
if (!thead || !tbody) return;
|
||
|
||
const headerRow = thead.rows[0];
|
||
if (!headerRow) return;
|
||
|
||
const headerCells = Array.from(headerRow.cells);
|
||
const colCount = headerCells.length;
|
||
|
||
// --- Column indices -------------------------------------------------------
|
||
let checkboxIdx = headerCells.findIndex(th =>
|
||
th.classList.contains("checkbox-col") ||
|
||
th.querySelector('input[type="checkbox"]')
|
||
);
|
||
|
||
let nameIdx = headerCells.findIndex(th =>
|
||
(th.dataset && th.dataset.column === "name") ||
|
||
/\bname\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (nameIdx < 0) {
|
||
nameIdx = Math.min(1, colCount - 1); // fallback to 2nd col
|
||
}
|
||
|
||
let sizeIdx = headerCells.findIndex(th =>
|
||
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
|
||
/\bsize\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (sizeIdx < 0) sizeIdx = -1;
|
||
|
||
let uploaderIdx = headerCells.findIndex(th =>
|
||
(th.dataset && th.dataset.column === "uploader") ||
|
||
/\buploader\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (uploaderIdx < 0) uploaderIdx = -1;
|
||
|
||
let actionsIdx = headerCells.findIndex(th =>
|
||
(th.dataset && th.dataset.column === "actions") ||
|
||
/\bactions?\b/i.test((th.textContent || "").trim()) ||
|
||
/\bactions?-col\b/i.test(th.className || "")
|
||
);
|
||
if (actionsIdx < 0) actionsIdx = -1;
|
||
|
||
// NEW: created / modified column indices (uploaded = created in your header)
|
||
let createdIdx = headerCells.findIndex(th =>
|
||
(th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) ||
|
||
/\b(uploaded|created)\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (createdIdx < 0) createdIdx = -1;
|
||
|
||
let modifiedIdx = headerCells.findIndex(th =>
|
||
(th.dataset && th.dataset.column === "modified") ||
|
||
/\bmodified\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (modifiedIdx < 0) modifiedIdx = -1;
|
||
|
||
// Remove any previous folder rows
|
||
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
||
|
||
|
||
|
||
const firstDataRow = tbody.firstElementChild;
|
||
|
||
subfolders.forEach(sf => {
|
||
const tr = document.createElement("tr");
|
||
tr.classList.add("folder-row");
|
||
tr.dataset.folder = sf.full;
|
||
|
||
// Allow dragging this folder row itself (for folder → folder moves)
|
||
tr.setAttribute('draggable', 'true');
|
||
tr.addEventListener('dragstart', e => folderRowDragStartHandler(e, sf.full));
|
||
|
||
for (let i = 0; i < colCount; i++) {
|
||
const td = document.createElement("td");
|
||
|
||
// *** copy header classes so responsive breakpoints match file rows ***
|
||
// but strip Bootstrap margin helpers (ml-2 / mx-2) so we don't get a big gap
|
||
const headerClass = headerCells[i] && headerCells[i].className;
|
||
if (headerClass) {
|
||
td.className = headerClass;
|
||
td.classList.remove("ml-2", "mx-2");
|
||
}
|
||
|
||
// 1) checkbox column (real checkbox; icon moves to name cell)
|
||
if (i === checkboxIdx) {
|
||
td.classList.add("folder-icon-cell", "checkbox-col");
|
||
td.style.textAlign = "center";
|
||
td.style.verticalAlign = "middle";
|
||
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox";
|
||
cb.className = "folder-checkbox";
|
||
cb.dataset.folder = sf.full;
|
||
cb.addEventListener("click", e => e.stopPropagation());
|
||
cb.addEventListener("change", () => handleFolderCheckboxChange(cb));
|
||
td.appendChild(cb);
|
||
|
||
// 2) name column
|
||
} else if (i === nameIdx) {
|
||
td.classList.add("name-cell", "file-name-cell", "folder-name-cell");
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "folder-row-inner";
|
||
|
||
const iconSpan = document.createElement("span");
|
||
iconSpan.className = "folder-svg folder-row-icon";
|
||
|
||
const nameSpan = document.createElement("span");
|
||
nameSpan.className = "folder-row-name";
|
||
nameSpan.textContent = sf.name || sf.full;
|
||
|
||
const metaSpan = document.createElement("span");
|
||
metaSpan.className = "folder-row-meta";
|
||
metaSpan.textContent = ""; // "(15 folders, 19 files)" later
|
||
|
||
wrap.appendChild(iconSpan);
|
||
wrap.appendChild(nameSpan);
|
||
wrap.appendChild(metaSpan);
|
||
td.appendChild(wrap);
|
||
|
||
// 3) size column
|
||
} else if (i === sizeIdx) {
|
||
td.classList.add("folder-size-cell");
|
||
td.textContent = "…"; // placeholder until we load stats
|
||
// NEW: match file-row numeric alignment
|
||
td.style.textAlign = "right";
|
||
td.style.fontVariantNumeric = "tabular-nums";
|
||
|
||
// 4) uploader / owner column
|
||
} else if (i === uploaderIdx) {
|
||
td.classList.add("uploader-cell", "folder-uploader-cell");
|
||
td.textContent = ""; // filled asynchronously with owner
|
||
|
||
// 5) actions column
|
||
} else if (i === actionsIdx) {
|
||
td.classList.add("folder-actions-cell");
|
||
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn btn-link btn-actions-ellipsis";
|
||
btn.title = t("more_actions");
|
||
|
||
const icon = document.createElement("span");
|
||
icon.className = "material-icons";
|
||
icon.textContent = "more_vert";
|
||
|
||
btn.appendChild(icon);
|
||
td.appendChild(btn);
|
||
}
|
||
|
||
// IMPORTANT: always append the cell, no matter which column we're in
|
||
tr.appendChild(td);
|
||
}
|
||
|
||
// click → navigate, same as before
|
||
tr.addEventListener("click", e => {
|
||
hideHoverPreview();
|
||
if (tr.classList.contains('inline-rename-active') || e.target.closest(".inline-rename-input")) {
|
||
return;
|
||
}
|
||
if (e.target.closest(".folder-checkbox")) {
|
||
return;
|
||
}
|
||
// If the click came from the 3-dot button, let the context menu logic handle it
|
||
if (e.target.closest(".btn-actions-ellipsis")) {
|
||
return;
|
||
}
|
||
|
||
if (e.button !== 0) return;
|
||
const dest = sf.full;
|
||
if (!dest) return;
|
||
const paneForRow = getPaneKeyForElement(tr);
|
||
setCurrentFolderContext(dest, { resetPage: true, pane: paneForRow });
|
||
|
||
document.querySelectorAll(".folder-option.selected")
|
||
.forEach(o => o.classList.remove("selected"));
|
||
const treeNode = document.querySelector(
|
||
`.folder-option[data-folder="${CSS.escape(dest)}"]`
|
||
);
|
||
if (treeNode) treeNode.classList.add("selected");
|
||
|
||
const strip = document.getElementById("folderStripContainer");
|
||
if (strip) {
|
||
strip.querySelectorAll(".folder-item.selected")
|
||
.forEach(i => i.classList.remove("selected"));
|
||
const stripItem = strip.querySelector(
|
||
`.folder-item[data-folder="${CSS.escape(dest)}"]`
|
||
);
|
||
if (stripItem) stripItem.classList.add("selected");
|
||
}
|
||
|
||
loadFileList(dest, { pane: paneForRow });
|
||
});
|
||
|
||
|
||
// DnD + context menu – keep existing logic, but also add a visual highlight
|
||
tr.addEventListener("dragover", e => {
|
||
folderDragOverHandler(e);
|
||
tr.classList.add("folder-row-droptarget");
|
||
});
|
||
|
||
tr.addEventListener("dragleave", e => {
|
||
folderDragLeaveHandler(e);
|
||
tr.classList.remove("folder-row-droptarget");
|
||
});
|
||
|
||
tr.addEventListener("drop", e => {
|
||
folderDropHandler(e);
|
||
tr.classList.remove("folder-row-droptarget");
|
||
});
|
||
|
||
const rowSourceId = getActivePaneSourceId();
|
||
|
||
tr.addEventListener("contextmenu", async e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const dest = sf.full;
|
||
if (!dest) return;
|
||
|
||
let caps = null;
|
||
try {
|
||
caps = await fetchFolderCaps(dest, rowSourceId);
|
||
} catch (e2) { /* ignore */ }
|
||
const enc = (caps && caps.encryption) ? caps.encryption : {};
|
||
const canEncrypt = !!enc.canEncrypt;
|
||
const canDecrypt = !!enc.canDecrypt;
|
||
|
||
const menuItems = [
|
||
{
|
||
label: t("create_folder"),
|
||
action: () => {
|
||
window.currentFolder = dest;
|
||
const modal = document.getElementById("createFolderModal");
|
||
if (modal) modal.style.display = "block";
|
||
const input = document.getElementById("newFolderName");
|
||
if (input) input.focus();
|
||
}
|
||
},
|
||
{ label: t("move_folder"), action: () => openMoveFolderUI(dest) },
|
||
{ label: t("rename_folder"), action: () => startInlineRenameForFolderRow(tr, dest) },
|
||
{ label: t("color_folder"), action: () => openColorFolderModal(dest) },
|
||
...(canEncrypt ? [{
|
||
label: 'Encrypt folder',
|
||
icon: 'lock',
|
||
action: () => startFolderCryptoJobFlow(dest, 'encrypt')
|
||
}] : []),
|
||
...(canDecrypt ? [{
|
||
label: 'Decrypt folder',
|
||
icon: 'lock_open',
|
||
action: () => startFolderCryptoJobFlow(dest, 'decrypt')
|
||
}] : []),
|
||
{ label: t("folder_share"), action: () => openFolderShareModal(dest) },
|
||
{ label: t("delete_folder"), action: () => { window.currentFolder = dest; openDeleteFolderModal(); } }
|
||
];
|
||
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
|
||
|
||
// Defensive: repaint icon if it was blanked by the contextmenu interaction.
|
||
try {
|
||
queueMicrotask(() => {
|
||
const iconSpan = tr.querySelector('.folder-svg');
|
||
if (iconSpan && String(iconSpan.innerHTML || '').trim() === '') {
|
||
attachStripIconAsync(tr, dest, 28, { sourceId: rowSourceId });
|
||
}
|
||
});
|
||
} catch (e2) { /* ignore */ }
|
||
});
|
||
|
||
// insert row above first file row
|
||
tbody.insertBefore(tr, firstDataRow || null);
|
||
|
||
// ----- ICON: color + alignment (size is driven by row height) -----
|
||
attachStripIconAsync(tr, sf.full, 28, { sourceId: rowSourceId });
|
||
const iconSpan = tr.querySelector(".folder-row-icon");
|
||
if (iconSpan) {
|
||
iconSpan.style.display = "inline-flex";
|
||
iconSpan.style.alignItems = "center";
|
||
iconSpan.style.justifyContent = "flex-start";
|
||
iconSpan.style.marginLeft = "0px"; // small left nudge
|
||
iconSpan.style.marginTop = "0px"; // small down nudge
|
||
}
|
||
|
||
// ----- FOLDER STATS + OWNER + CAPS -----
|
||
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
||
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
||
const createdCellIndex = (createdIdx >= 0 && createdIdx < tr.cells.length) ? createdIdx : -1;
|
||
const modifiedCellIndex = (modifiedIdx >= 0 && modifiedIdx < tr.cells.length) ? modifiedIdx : -1;
|
||
|
||
fetchFolderStats(sf.full, rowSourceId).then(stats => {
|
||
if (!stats) return;
|
||
|
||
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
||
// Try multiple possible size keys so backend + JS can drift a bit
|
||
let bytes = null;
|
||
const sizeCandidates = [
|
||
stats.bytes,
|
||
stats.sizeBytes,
|
||
stats.size,
|
||
stats.totalBytes
|
||
];
|
||
for (const v of sizeCandidates) {
|
||
const n = Number(v);
|
||
if (Number.isFinite(n) && n >= 0) {
|
||
bytes = n;
|
||
break;
|
||
}
|
||
}
|
||
|
||
let pieces = [];
|
||
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
||
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
|
||
if (!pieces.length) pieces.push("0 items");
|
||
const countLabel = pieces.join(", ");
|
||
|
||
if (nameCellIndex >= 0) {
|
||
const nameCell = tr.cells[nameCellIndex];
|
||
if (nameCell) {
|
||
const metaSpan = nameCell.querySelector(".folder-row-meta");
|
||
if (metaSpan) metaSpan.textContent = ` (${countLabel})`;
|
||
}
|
||
}
|
||
|
||
if (sizeCellIndex >= 0) {
|
||
const sizeCell = tr.cells[sizeCellIndex];
|
||
if (sizeCell) {
|
||
let sizeLabel = "—";
|
||
if (bytes != null && bytes >= 0) {
|
||
sizeLabel = formatSize(bytes);
|
||
}
|
||
sizeCell.textContent = sizeLabel;
|
||
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
||
}
|
||
}
|
||
|
||
if (createdCellIndex >= 0) {
|
||
const createdCell = tr.cells[createdCellIndex];
|
||
if (createdCell) {
|
||
const txt = (stats && typeof stats.earliest_uploaded === 'string')
|
||
? stats.earliest_uploaded
|
||
: '';
|
||
createdCell.textContent = txt;
|
||
}
|
||
}
|
||
|
||
if (modifiedCellIndex >= 0) {
|
||
const modCell = tr.cells[modifiedCellIndex];
|
||
if (modCell) {
|
||
const txt = (stats && typeof stats.latest_mtime === 'string')
|
||
? stats.latest_mtime
|
||
: '';
|
||
modCell.textContent = txt;
|
||
}
|
||
}
|
||
}).catch(() => {
|
||
if (sizeCellIndex >= 0) {
|
||
const sizeCell = tr.cells[sizeCellIndex];
|
||
if (sizeCell && !sizeCell.textContent) sizeCell.textContent = "—";
|
||
}
|
||
});
|
||
|
||
// OWNER + action permissions
|
||
if (uploaderIdx >= 0 || actionsIdx >= 0) {
|
||
fetchFolderCaps(sf.full, rowSourceId).then(caps => {
|
||
if (!caps || !document.body.contains(tr)) return;
|
||
|
||
if (uploaderIdx >= 0 && uploaderIdx < tr.cells.length) {
|
||
const uploaderCell = tr.cells[uploaderIdx];
|
||
if (uploaderCell) {
|
||
const owner = caps.owner || caps.user || "";
|
||
uploaderCell.textContent = owner || "";
|
||
}
|
||
}
|
||
|
||
if (actionsIdx >= 0 && actionsIdx < tr.cells.length) {
|
||
const actCell = tr.cells[actionsIdx];
|
||
if (!actCell) return;
|
||
|
||
actCell.querySelectorAll('button[data-folder-action]').forEach(btn => {
|
||
const action = btn.getAttribute('data-folder-action');
|
||
let enabled = false;
|
||
switch (action) {
|
||
case "move":
|
||
enabled = !!caps.canMoveFolder;
|
||
break;
|
||
case "color":
|
||
enabled = !!caps.canRename; // same gate as tree “color” button
|
||
break;
|
||
case "rename":
|
||
enabled = !!caps.canRename;
|
||
break;
|
||
case "share":
|
||
enabled = !!caps.canShareFolder;
|
||
break;
|
||
}
|
||
if (enabled === undefined) {
|
||
enabled = true; // fallback so admin still gets buttons even if a flag is missing
|
||
}
|
||
if (enabled) {
|
||
btn.disabled = false;
|
||
btn.style.pointerEvents = "";
|
||
btn.style.opacity = "";
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.style.pointerEvents = "none";
|
||
btn.style.opacity = "0.5";
|
||
}
|
||
});
|
||
}
|
||
}).catch(() => { /* ignore */ });
|
||
}
|
||
});
|
||
syncFolderIconSizeToRowHeight();
|
||
}
|
||
function syncFolderIconSizeToRowHeight() {
|
||
const cs = getComputedStyle(document.documentElement);
|
||
const raw = cs.getPropertyValue('--file-row-height') || '44px';
|
||
const rowH = parseInt(raw, 10) || 60;
|
||
|
||
const FUDGE = 1;
|
||
const MAX_GROWTH_ROW = 44; // after this, stop growing the icon
|
||
|
||
const BASE_ROW_FOR_OFFSET = 20; // where icon looks centered
|
||
const OFFSET_FACTOR = -0.10;
|
||
const effectiveRow = Math.min(rowH, MAX_GROWTH_ROW);
|
||
|
||
const boxSize = Math.max(20, Math.min(35, effectiveRow - 20 + FUDGE));
|
||
const scale = 1.20;
|
||
|
||
// use existing offset curve
|
||
const clampedForOffset = Math.max(30, Math.min(60, rowH));
|
||
let offsetY = (clampedForOffset - BASE_ROW_FOR_OFFSET) * OFFSET_FACTOR;
|
||
if (rowH > 53) {
|
||
offsetY -= -2;
|
||
}
|
||
|
||
document.querySelectorAll(':is(#fileList, #fileListSecondary) .folder-row-icon').forEach(iconSpan => {
|
||
iconSpan.style.width = boxSize + 'px';
|
||
iconSpan.style.height = boxSize + 'px';
|
||
iconSpan.style.overflow = 'visible';
|
||
|
||
const svg = iconSpan.querySelector('svg');
|
||
if (!svg) return;
|
||
|
||
svg.setAttribute('width', String(boxSize));
|
||
svg.setAttribute('height', String(boxSize));
|
||
svg.style.transformOrigin = 'left center';
|
||
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
|
||
});
|
||
}
|
||
|
||
async function sortSubfoldersForCurrentOrder(subfolders) {
|
||
const base = Array.isArray(subfolders) ? [...subfolders] : [];
|
||
if (!base.length) return base;
|
||
|
||
const sourceId = getActivePaneSourceId();
|
||
const col = sortOrder?.column || "uploaded";
|
||
const ascending = sortOrder?.ascending !== false;
|
||
const dir = ascending ? 1 : -1;
|
||
|
||
// Name sort (A–Z / Z–A)
|
||
if (col === "name") {
|
||
base.sort((a, b) => {
|
||
const n1 = (a.name || "").toLowerCase();
|
||
const n2 = (b.name || "").toLowerCase();
|
||
if (n1 < n2) return -1 * dir;
|
||
if (n1 > n2) return 1 * dir;
|
||
return 0;
|
||
});
|
||
return base;
|
||
}
|
||
|
||
// Size sort – use folder stats (bytes)
|
||
if (col === "size" || col === "filesize") {
|
||
const statsList = await Promise.all(
|
||
base.map(sf => fetchFolderStats(sf.full, sourceId).catch(() => null))
|
||
);
|
||
|
||
const decorated = base.map((sf, idx) => {
|
||
const stats = statsList[idx];
|
||
let bytes = 0;
|
||
|
||
if (stats) {
|
||
const candidates = [
|
||
stats.bytes,
|
||
stats.sizeBytes,
|
||
stats.size,
|
||
stats.totalBytes
|
||
];
|
||
for (const v of candidates) {
|
||
const n = Number(v);
|
||
if (Number.isFinite(n) && n >= 0) {
|
||
bytes = n;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { sf, bytes };
|
||
});
|
||
|
||
decorated.sort((a, b) => {
|
||
if (a.bytes < b.bytes) return -1 * dir;
|
||
if (a.bytes > b.bytes) return 1 * dir;
|
||
|
||
// tie-break by name
|
||
const n1 = (a.sf.name || "").toLowerCase();
|
||
const n2 = (b.sf.name || "").toLowerCase();
|
||
if (n1 < n2) return -1 * dir;
|
||
if (n1 > n2) return 1 * dir;
|
||
return 0;
|
||
});
|
||
|
||
return decorated.map(d => d.sf);
|
||
}
|
||
|
||
// NEW: Created / Uploaded sort – use earliest_uploaded from stats
|
||
if (col === "uploaded" || col === "created") {
|
||
const statsList = await Promise.all(
|
||
base.map(sf => fetchFolderStats(sf.full, sourceId).catch(() => null))
|
||
);
|
||
|
||
const decorated = base.map((sf, idx) => {
|
||
const stats = statsList[idx];
|
||
let ts = 0;
|
||
|
||
if (stats && typeof stats.earliest_uploaded === "string") {
|
||
ts = parseCustomDate(String(stats.earliest_uploaded));
|
||
if (!Number.isFinite(ts)) ts = 0;
|
||
}
|
||
|
||
return { sf, ts };
|
||
});
|
||
|
||
decorated.sort((a, b) => {
|
||
if (a.ts < b.ts) return -1 * dir;
|
||
if (a.ts > b.ts) return 1 * dir;
|
||
|
||
// tie-break by name
|
||
const n1 = (a.sf.name || "").toLowerCase();
|
||
const n2 = (b.sf.name || "").toLowerCase();
|
||
if (n1 < n2) return -1 * dir;
|
||
if (n1 > n2) return 1 * dir;
|
||
return 0;
|
||
});
|
||
|
||
return decorated.map(d => d.sf);
|
||
}
|
||
|
||
// NEW: Modified sort – use latest_mtime from stats
|
||
if (col === "modified") {
|
||
const statsList = await Promise.all(
|
||
base.map(sf => fetchFolderStats(sf.full, sourceId).catch(() => null))
|
||
);
|
||
|
||
const decorated = base.map((sf, idx) => {
|
||
const stats = statsList[idx];
|
||
let ts = 0;
|
||
|
||
if (stats && typeof stats.latest_mtime === "string") {
|
||
ts = parseCustomDate(String(stats.latest_mtime));
|
||
if (!Number.isFinite(ts)) ts = 0;
|
||
}
|
||
|
||
return { sf, ts };
|
||
});
|
||
|
||
decorated.sort((a, b) => {
|
||
if (a.ts < b.ts) return -1 * dir;
|
||
if (a.ts > b.ts) return 1 * dir;
|
||
|
||
// tie-break by name
|
||
const n1 = (a.sf.name || "").toLowerCase();
|
||
const n2 = (b.sf.name || "").toLowerCase();
|
||
if (n1 < n2) return -1 * dir;
|
||
if (n1 > n2) return 1 * dir;
|
||
return 0;
|
||
});
|
||
|
||
return decorated.map(d => d.sf);
|
||
}
|
||
|
||
// Default: keep folders A–Z by name regardless of other sorts
|
||
base.sort((a, b) =>
|
||
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
|
||
);
|
||
return base;
|
||
}
|
||
|
||
async function openDefaultFileFromHover(file) {
|
||
if (!file) return;
|
||
const folder = file.folder || window.currentFolder || "root";
|
||
const sourceId = String(file.sourceId || '').trim() || getActivePaneSourceId();
|
||
|
||
try {
|
||
if (canEditFile(file.name) && file.editable) {
|
||
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||
m.editFile(file.name, folder, sourceId, file.sizeBytes);
|
||
} else {
|
||
const url = apiFileUrl(folder, file.name, true, sourceId);
|
||
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||
m.previewFile(url, file.name);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to open hover preview action", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render table view
|
||
*/
|
||
|
||
|
||
function getSelectedFileValuesForList(listEl) {
|
||
if (!listEl) return [];
|
||
return Array.from(listEl.querySelectorAll('tbody .file-checkbox:checked'))
|
||
.map(cb => cb.value);
|
||
}
|
||
|
||
function restoreSelectedFileValues(listEl, selectedValues) {
|
||
if (!listEl || !Array.isArray(selectedValues) || !selectedValues.length) return;
|
||
const selectedSet = new Set(selectedValues.map(v => String(v)));
|
||
listEl.querySelectorAll('tbody .file-checkbox').forEach(cb => {
|
||
const raw = cb.value;
|
||
const decoded = decodeHtmlEntities(raw);
|
||
if (selectedSet.has(raw) || (decoded && selectedSet.has(decoded))) {
|
||
cb.checked = true;
|
||
updateRowHighlight(cb);
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function renderFileTable(folder, container, subfolders, options = {}) {
|
||
clearInlineRenameState({ restore: false });
|
||
const fileListContent = container || document.getElementById("fileList");
|
||
const preserveSelection = options && options.preserveSelection === true;
|
||
let preservedSelection = [];
|
||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10);
|
||
const tablePane = getPaneKeyForElement(fileListContent);
|
||
const panePaging = getPaneFileListPaging(tablePane);
|
||
const serverPagingActive = !!(panePaging && searchTerm === "" && !window.advancedSearchEnabled);
|
||
let currentPage = serverPagingActive
|
||
? (Number(panePaging.page) || 1)
|
||
: (window.currentPage || 1);
|
||
const targetSelection = (pendingSearchSelection && pendingSearchSelection.folder === folder)
|
||
? pendingSearchSelection
|
||
: null;
|
||
|
||
// Files (filtered by search)
|
||
let filteredFiles = searchFiles(searchTerm);
|
||
|
||
// Apply current sort (Modified desc by default for you)
|
||
if (!serverPagingActive && Array.isArray(filteredFiles) && filteredFiles.length) {
|
||
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
|
||
}
|
||
|
||
// Inline folders: sort once (Explorer-style A→Z)
|
||
const subfoldersSourceId = String(window.currentSubfoldersSourceId || '');
|
||
const subfoldersFolder = String(window.currentSubfoldersFolder || '');
|
||
const paneSourceId = String(getPaneSourceIdForElement(fileListContent) || getActivePaneSourceId() || '');
|
||
const subfoldersMatch = subfoldersSourceId === paneSourceId && subfoldersFolder === String(folder || '');
|
||
const allSubfolders = (subfoldersMatch && Array.isArray(window.currentSubfolders))
|
||
? window.currentSubfolders
|
||
: [];
|
||
|
||
// NEW: sort folders according to current sort order (name / size)
|
||
const subfoldersSorted = await sortSubfoldersForCurrentOrder(allSubfolders);
|
||
|
||
if (preserveSelection) {
|
||
// Capture after async work so user selections during the wait aren't lost.
|
||
preservedSelection = getSelectedFileValuesForList(fileListContent);
|
||
}
|
||
|
||
const totalFiles = filteredFiles.length;
|
||
const totalFolders = subfoldersSorted.length;
|
||
const totalRows = totalFiles + totalFolders;
|
||
const hasFolders = totalFolders > 0;
|
||
const folderPagingInServerMode =
|
||
serverPagingActive &&
|
||
window.showInlineFolders !== false &&
|
||
hasFolders;
|
||
let totalPages = 1;
|
||
let pageFolders = [];
|
||
let pageFiles = [];
|
||
|
||
if (serverPagingActive) {
|
||
const totalFilesKnown = Number.isFinite(Number(panePaging.total))
|
||
? Math.max(0, Number(panePaging.total))
|
||
: Math.max(0, totalFiles);
|
||
|
||
if (folderPagingInServerMode) {
|
||
const currentCursor = String((panePaging && panePaging.cursor != null) ? panePaging.cursor : '');
|
||
const cursorOffset = parseCursorOffset(currentCursor);
|
||
const inferredPage = Math.floor((totalFolders + cursorOffset) / itemsPerPageSetting) + 1;
|
||
const preferredPageRaw = Number(window.currentPage || inferredPage);
|
||
const preferredPage = Number.isFinite(preferredPageRaw) ? preferredPageRaw : inferredPage;
|
||
const layout = getCombinedPageLayout({
|
||
totalFolders,
|
||
totalFiles: totalFilesKnown,
|
||
itemsPerPage: itemsPerPageSetting,
|
||
targetPage: preferredPage
|
||
});
|
||
|
||
totalPages = layout.totalPages;
|
||
currentPage = layout.page;
|
||
pageFolders = subfoldersSorted.slice(layout.folderStart, layout.folderStart + layout.folderCount);
|
||
|
||
if (layout.fileCount > 0) {
|
||
const loadedCount = Math.max(0, Array.isArray(filteredFiles) ? filteredFiles.length : 0);
|
||
const rangeStart = layout.fileStart;
|
||
const rangeEnd = layout.fileStart + layout.fileCount;
|
||
const loadedEnd = cursorOffset + loadedCount;
|
||
const hasRange = cursorOffset <= rangeStart && loadedEnd >= rangeEnd;
|
||
|
||
if (!hasRange) {
|
||
const targetCursor = String(rangeStart);
|
||
if (targetCursor !== currentCursor) {
|
||
window.currentPage = currentPage;
|
||
savePaneState(tablePane, {
|
||
currentPage,
|
||
currentSearchTerm: window.currentSearchTerm || ''
|
||
});
|
||
loadFileList(folder, { pane: tablePane, cursor: targetCursor });
|
||
return;
|
||
}
|
||
}
|
||
|
||
const localStart = Math.max(0, rangeStart - cursorOffset);
|
||
pageFiles = filteredFiles.slice(localStart, localStart + layout.fileCount);
|
||
} else {
|
||
pageFiles = [];
|
||
}
|
||
} else {
|
||
const pagingTotalPages = Number.isFinite(Number(panePaging.totalPages)) ? Number(panePaging.totalPages) : 1;
|
||
const pagingPage = Number.isFinite(Number(panePaging.page)) ? Number(panePaging.page) : currentPage;
|
||
const fileTotalPages = Math.max(1, pagingTotalPages || (panePaging.hasMore ? pagingPage + 1 : pagingPage) || 1);
|
||
const filePage = Math.max(1, Math.min(fileTotalPages, pagingPage || 1));
|
||
totalPages = fileTotalPages;
|
||
currentPage = Math.max(1, Math.min(totalPages, filePage || 1));
|
||
// Legacy server-paging behavior: keep folders visible above each file page.
|
||
pageFolders = subfoldersSorted;
|
||
pageFiles = filteredFiles;
|
||
}
|
||
|
||
window.currentPage = currentPage;
|
||
} else {
|
||
// If we have a pending search target, jump to the page that contains it
|
||
if (targetSelection && totalFiles > 0) {
|
||
const idx = filteredFiles.findIndex(f => f.name === targetSelection.name);
|
||
if (idx >= 0) {
|
||
const targetPage = Math.floor(idx / itemsPerPageSetting) + 1;
|
||
if (targetPage !== currentPage) {
|
||
currentPage = targetPage;
|
||
window.currentPage = currentPage;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pagination is now over (folders + files)
|
||
totalPages = totalRows > 0
|
||
? Math.ceil(totalRows / itemsPerPageSetting)
|
||
: 1;
|
||
|
||
if (currentPage > totalPages) {
|
||
currentPage = totalPages;
|
||
window.currentPage = currentPage;
|
||
}
|
||
|
||
const startRow = (currentPage - 1) * itemsPerPageSetting;
|
||
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
|
||
|
||
// Figure out which folders + files belong to THIS page
|
||
for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) {
|
||
if (rowIndex < totalFolders) {
|
||
pageFolders.push(subfoldersSorted[rowIndex]);
|
||
} else {
|
||
const fileIdx = rowIndex - totalFolders;
|
||
const file = filteredFiles[fileIdx];
|
||
if (file) pageFiles.push(file);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keep pane-local pagination state in sync so click-capture pane activation
|
||
// does not restore a stale page before pagination handlers run.
|
||
savePaneState(tablePane, {
|
||
currentPage,
|
||
currentSearchTerm: window.currentSearchTerm || ''
|
||
});
|
||
|
||
// Stable id per file row on this page
|
||
const rowIdFor = (file, idx) =>
|
||
`${encodeURIComponent(file.name)}-p${currentPage}-${idx}`;
|
||
|
||
// We pass a harmless "base" string to keep buildFileTableRow happy,
|
||
// then we will FIX the preview/thumbnail URLs to the API below.
|
||
const fakeBase = "#/";
|
||
|
||
const topControlsHTML = buildSearchAndPaginationControls({
|
||
currentPage,
|
||
totalPages,
|
||
searchTerm: window.currentSearchTerm || ""
|
||
});
|
||
|
||
const combinedTopHTML = topControlsHTML;
|
||
|
||
let headerHTML = buildFileTableHeader(sortOrder);
|
||
|
||
headerHTML = headerHTML.replace(/<table([^>]*)>/i, (full, attrs) => {
|
||
// If table already has class="", append filr-table. Otherwise add class attribute.
|
||
if (/class\s*=\s*"/i.test(attrs)) {
|
||
return full.replace(/class="([^"]*)"/i, (m, cls) => `class="${cls} filr-table"`);
|
||
}
|
||
return `<table class="filr-table"${attrs}>`;
|
||
});
|
||
|
||
let rowsHTML = "<tbody>";
|
||
|
||
if (pageFiles.length > 0) {
|
||
pageFiles.forEach((file, idx) => {
|
||
const rowKey = rowIdFor(file, idx);
|
||
let rowHTML = buildFileTableRow(file, fakeBase);
|
||
|
||
// add row id + data-file-name, and ensure the name cell also has "name-cell"
|
||
rowHTML = rowHTML
|
||
.replace("<tr", `<tr id="file-row-${rowKey}" data-file-name="${escapeHTML(file.name)}"`)
|
||
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
|
||
|
||
let tagBadgesHTML = "";
|
||
if (file.tags && file.tags.length > 0) {
|
||
tagBadgesHTML = '<div class="tag-badges" style="display:inline-block; margin-left:5px;">';
|
||
file.tags.forEach(tag => {
|
||
const safeColor = sanitizeTagColor(tag.color);
|
||
tagBadgesHTML += `<span style="background-color: ${safeColor}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
|
||
});
|
||
tagBadgesHTML += "</div>";
|
||
}
|
||
|
||
rowsHTML += rowHTML.replace(
|
||
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
|
||
(m, open, inner, close) => {
|
||
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
|
||
}
|
||
);
|
||
});
|
||
} else if (!hasFolders && totalFiles === 0) {
|
||
// Only show "No files found" if there are no folders either
|
||
rowsHTML += `<tr><td colspan="8">${t("no_files_found") || "No files found."}</td></tr>`;
|
||
}
|
||
|
||
rowsHTML += "</tbody></table>";
|
||
|
||
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
|
||
|
||
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
|
||
updatePaneWidthClasses();
|
||
restoreSelectedFileValues(fileListContent, preservedSelection);
|
||
|
||
(function rightAlignSizeColumn() {
|
||
const table = fileListContent.querySelector("table.filr-table");
|
||
if (!table || !table.tHead || !table.tBodies.length) return;
|
||
|
||
const headerCells = Array.from(table.tHead.querySelectorAll("th"));
|
||
const sizeIdx = headerCells.findIndex(th =>
|
||
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
|
||
/\bsize\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (sizeIdx < 0) return;
|
||
|
||
// Header
|
||
headerCells[sizeIdx].style.textAlign = "right";
|
||
|
||
// Body cells
|
||
Array.from(table.tBodies[0].rows).forEach(row => {
|
||
if (sizeIdx >= row.cells.length) return;
|
||
row.cells[sizeIdx].style.textAlign = "right";
|
||
row.cells[sizeIdx].style.fontVariantNumeric = "tabular-nums";
|
||
});
|
||
})();
|
||
|
||
// ---- MOBILE FIX: show "Size" column for files (Name | Size | Actions) ----
|
||
(function fixMobileFileSizeColumn() {
|
||
const mode = getPaneLayoutMode(fileListContent);
|
||
if (mode !== "narrow") return;
|
||
|
||
const table = fileListContent.querySelector("table.filr-table");
|
||
if (!table || !table.tHead || !table.tBodies.length) return;
|
||
|
||
const thead = table.tHead;
|
||
const tbody = table.tBodies[0];
|
||
|
||
const headerCells = Array.from(thead.querySelectorAll("th"));
|
||
// Find the Size column index by label or data-column
|
||
const sizeIdx = headerCells.findIndex(th =>
|
||
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
|
||
/\bsize\b/i.test((th.textContent || "").trim())
|
||
);
|
||
if (sizeIdx < 0) return;
|
||
|
||
// Unhide Size header on mobile
|
||
const sizeTh = headerCells[sizeIdx];
|
||
sizeTh.classList.remove(
|
||
"hide-small",
|
||
"hide-medium",
|
||
"d-none",
|
||
"d-sm-table-cell",
|
||
"d-md-table-cell",
|
||
"d-lg-table-cell",
|
||
"d-xl-table-cell"
|
||
);
|
||
|
||
// Unhide the Size cell in every body row (files + folders)
|
||
Array.from(tbody.rows).forEach(row => {
|
||
if (sizeIdx >= row.cells.length) return;
|
||
const td = row.cells[sizeIdx];
|
||
if (!td) return;
|
||
|
||
td.classList.remove(
|
||
"hide-small",
|
||
"hide-medium",
|
||
"d-none",
|
||
"d-sm-table-cell",
|
||
"d-md-table-cell",
|
||
"d-lg-table-cell",
|
||
"d-xl-table-cell"
|
||
);
|
||
});
|
||
})();
|
||
|
||
// Inject inline folder rows for THIS page (Explorer-style) first
|
||
if (window.showInlineFolders !== false && pageFolders.length) {
|
||
injectInlineFolderRows(fileListContent, folder, pageFolders);
|
||
}
|
||
|
||
// Right-align meta columns: created / modified / owner
|
||
(function rightAlignMetaColumns() {
|
||
const table = fileListContent.querySelector("table.filr-table");
|
||
if (!table || !table.tHead || !table.tBodies.length) return;
|
||
|
||
const headerCells = Array.from(table.tHead.querySelectorAll("th"));
|
||
const bodyRows = Array.from(table.tBodies[0].rows);
|
||
|
||
function alignCol(matchFn, numeric = true) {
|
||
const idx = headerCells.findIndex(matchFn);
|
||
if (idx < 0) return;
|
||
|
||
const th = headerCells[idx];
|
||
th.style.textAlign = "right";
|
||
|
||
bodyRows.forEach(row => {
|
||
if (idx >= row.cells.length) return;
|
||
const td = row.cells[idx];
|
||
if (!td) return;
|
||
td.style.textAlign = "right";
|
||
if (numeric) {
|
||
td.style.fontVariantNumeric = "tabular-nums";
|
||
}
|
||
});
|
||
}
|
||
|
||
// Uploaded / Created
|
||
alignCol(th =>
|
||
(th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) ||
|
||
/\b(uploaded|created)\b/i.test((th.textContent || "").trim())
|
||
);
|
||
|
||
// Modified
|
||
alignCol(th =>
|
||
(th.dataset && th.dataset.column === "modified") ||
|
||
/\bmodified\b/i.test((th.textContent || "").trim())
|
||
);
|
||
|
||
// Owner / Uploader
|
||
alignCol(th =>
|
||
(th.dataset && th.dataset.column === "uploader") ||
|
||
/\b(owner|uploader)\b/i.test((th.textContent || "").trim()),
|
||
/* numeric = */ false // names aren't numbers, but right-align anyway
|
||
);
|
||
})();
|
||
|
||
// Now wire 3-dot ellipsis so it also picks up folder rows
|
||
wireEllipsisContextMenu(fileListContent);
|
||
|
||
// Hover preview (desktop only, and only if user didn’t disable it)
|
||
if (window.innerWidth >= 768 && !isHoverPreviewDisabled()) {
|
||
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||
if (row.classList.contains("folder-strip-row")) return;
|
||
|
||
row.addEventListener("mouseenter", (e) => {
|
||
hoverPreviewActiveRow = row;
|
||
clearTimeout(hoverPreviewTimer);
|
||
hoverPreviewTimer = setTimeout(() => {
|
||
if (hoverPreviewActiveRow === row && !isHoverPreviewDisabled()) {
|
||
fillHoverPreviewForRow(row);
|
||
const el = ensureHoverPreviewEl();
|
||
el.style.display = "block";
|
||
positionHoverPreview(e.clientX, e.clientY);
|
||
}
|
||
}, 180);
|
||
});
|
||
|
||
row.addEventListener("mouseleave", () => {
|
||
hoverPreviewActiveRow = null;
|
||
clearTimeout(hoverPreviewTimer);
|
||
setTimeout(() => {
|
||
if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
|
||
hideHoverPreview();
|
||
}
|
||
}, 120);
|
||
});
|
||
|
||
row.addEventListener("contextmenu", () => {
|
||
hoverPreviewActiveRow = null;
|
||
clearTimeout(hoverPreviewTimer);
|
||
hideHoverPreview();
|
||
});
|
||
});
|
||
}
|
||
|
||
wireSelectAll(fileListContent);
|
||
|
||
// PATCH each row's preview/thumb to use the secure API URLs
|
||
// PATCH each row's preview/thumb to use the secure API URLs
|
||
if (pageFiles.length > 0) {
|
||
pageFiles.forEach((file, idx) => {
|
||
const rowKey = rowIdFor(file, idx);
|
||
const rowEl = document.getElementById(`file-row-${rowKey}`);
|
||
if (!rowEl) return;
|
||
|
||
const rowSourceId = String(file.sourceId || paneSourceId || '').trim();
|
||
const previewUrl = apiFileUrl(file.folder || folder, file.name, true, rowSourceId);
|
||
|
||
// Preview button dataset
|
||
const previewBtn = rowEl.querySelector(".preview-btn");
|
||
if (previewBtn) {
|
||
previewBtn.dataset.previewUrl = previewUrl;
|
||
previewBtn.dataset.previewName = file.name;
|
||
}
|
||
|
||
// Thumbnail (if present)
|
||
const thumbImg = rowEl.querySelector("img");
|
||
if (thumbImg) {
|
||
thumbImg.src = previewUrl;
|
||
thumbImg.setAttribute("data-cache-key", previewUrl);
|
||
}
|
||
|
||
// Any anchor that might have been built to point at a file path
|
||
rowEl.querySelectorAll('a[href]').forEach(a => {
|
||
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
|
||
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
|
||
a.href = previewUrl;
|
||
});
|
||
});
|
||
}
|
||
|
||
fileListContent.querySelectorAll('.folder-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const dest = String(el.dataset.folder || '').trim();
|
||
if (!dest) return;
|
||
setCurrentFolderContext(dest, { pane: tablePane, resetPage: true });
|
||
loadFileList(dest, { pane: tablePane });
|
||
});
|
||
});
|
||
|
||
const goToCombinedServerPage = (targetPage) => {
|
||
const totalFilesKnown = Number.isFinite(Number(panePaging?.total))
|
||
? Math.max(0, Number(panePaging.total))
|
||
: Math.max(0, totalFiles);
|
||
const layout = getCombinedPageLayout({
|
||
totalFolders,
|
||
totalFiles: totalFilesKnown,
|
||
itemsPerPage: itemsPerPageSetting,
|
||
targetPage
|
||
});
|
||
|
||
window.currentPage = layout.page;
|
||
savePaneState(tablePane, {
|
||
currentPage: window.currentPage,
|
||
currentSearchTerm: window.currentSearchTerm || ''
|
||
});
|
||
|
||
if (layout.fileCount <= 0) {
|
||
renderFileTable(folder, fileListContent);
|
||
return;
|
||
}
|
||
|
||
const targetCursor = String(layout.fileStart);
|
||
const currentCursor = String((panePaging && panePaging.cursor != null) ? panePaging.cursor : '');
|
||
const cursorOffset = parseCursorOffset(currentCursor);
|
||
const loadedCount = Math.max(0, Array.isArray(filteredFiles) ? filteredFiles.length : 0);
|
||
const loadedEnd = cursorOffset + loadedCount;
|
||
const requiredEnd = layout.fileStart + layout.fileCount;
|
||
const hasRange = cursorOffset <= layout.fileStart && loadedEnd >= requiredEnd;
|
||
|
||
if (currentCursor === targetCursor && hasRange) {
|
||
renderFileTable(folder, fileListContent);
|
||
return;
|
||
}
|
||
|
||
loadFileList(folder, { pane: tablePane, cursor: targetCursor });
|
||
};
|
||
|
||
// pagination clicks
|
||
const prevBtn = document.getElementById("prevPageBtn");
|
||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||
if (serverPagingActive) {
|
||
if (folderPagingInServerMode) {
|
||
const virtualPage = Math.max(1, Number(window.currentPage || currentPage || 1));
|
||
if (virtualPage > 1) {
|
||
goToCombinedServerPage(virtualPage - 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const prevCursor = panePaging && panePaging.prevCursor != null
|
||
? String(panePaging.prevCursor)
|
||
: '';
|
||
if (prevCursor !== '') {
|
||
loadFileList(folder, { pane: tablePane, cursor: prevCursor });
|
||
}
|
||
return;
|
||
}
|
||
if (window.currentPage > 1) {
|
||
window.currentPage--;
|
||
renderFileTable(folder, container);
|
||
}
|
||
});
|
||
const nextBtn = document.getElementById("nextPageBtn");
|
||
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||
if (serverPagingActive) {
|
||
if (folderPagingInServerMode) {
|
||
const virtualPage = Math.max(1, Number(window.currentPage || currentPage || 1));
|
||
if (virtualPage < totalPages) {
|
||
goToCombinedServerPage(virtualPage + 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const nextCursor = panePaging && panePaging.nextCursor != null
|
||
? String(panePaging.nextCursor)
|
||
: '';
|
||
if (nextCursor !== '') {
|
||
loadFileList(folder, { pane: tablePane, cursor: nextCursor });
|
||
}
|
||
return;
|
||
}
|
||
if (window.currentPage < totalPages) {
|
||
window.currentPage++;
|
||
renderFileTable(folder, container);
|
||
}
|
||
});
|
||
|
||
// advanced search toggle
|
||
const advToggle = document.getElementById("advancedSearchToggle");
|
||
if (advToggle) advToggle.addEventListener("click", () => {
|
||
toggleAdvancedSearch();
|
||
});
|
||
|
||
// items-per-page selector
|
||
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||
window.itemsPerPage = parseInt(e.target.value, 10);
|
||
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||
window.currentPage = 1;
|
||
if (serverPagingActive) {
|
||
loadFileList(folder, { pane: tablePane, cursor: "" });
|
||
return;
|
||
}
|
||
renderFileTable(folder, container);
|
||
});
|
||
|
||
// Row-select (only file rows have checkboxes; folder rows are ignored here)
|
||
fileListContent.querySelectorAll("tbody tr").forEach(row => {
|
||
row.addEventListener("click", e => {
|
||
clearFolderSelections();
|
||
const cb = row.querySelector(".file-checkbox");
|
||
if (!cb) return;
|
||
toggleRowSelection(e, cb.value);
|
||
});
|
||
});
|
||
|
||
|
||
|
||
// Download buttons
|
||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||
btn.addEventListener("click", e => {
|
||
e.stopPropagation();
|
||
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||
});
|
||
});
|
||
|
||
// Edit buttons
|
||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||
btn.addEventListener("click", async e => {
|
||
e.stopPropagation();
|
||
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||
const rawSize = btn.dataset.editSizeBytes;
|
||
const sizeNum = (rawSize === undefined || rawSize === '') ? NaN : Number(rawSize);
|
||
const sizeArg = Number.isFinite(sizeNum) ? sizeNum : null;
|
||
m.editFile(btn.dataset.editName, btn.dataset.editFolder, paneSourceId, sizeArg);
|
||
});
|
||
});
|
||
|
||
// Rename buttons
|
||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||
btn.addEventListener("click", async e => {
|
||
e.stopPropagation();
|
||
const m = await import('./fileActions.js?v={{APP_QVER}}');
|
||
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||
});
|
||
});
|
||
|
||
// Preview buttons
|
||
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
|
||
btn.addEventListener("click", async e => {
|
||
e.stopPropagation();
|
||
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
|
||
});
|
||
});
|
||
|
||
createViewToggleButton();
|
||
ensureSearchEverywhereButton();
|
||
bindViewOptionsButton();
|
||
|
||
// search input
|
||
const newSearchInput = document.getElementById("searchInput");
|
||
if (newSearchInput) {
|
||
newSearchInput.addEventListener("input", debounce(function () {
|
||
window.currentSearchTerm = newSearchInput.value;
|
||
window.currentPage = 1;
|
||
savePaneState(tablePane, {
|
||
currentSearchTerm: window.currentSearchTerm || '',
|
||
currentPage: window.currentPage || 1
|
||
});
|
||
const livePaging = getPaneFileListPaging(tablePane);
|
||
const trimmed = String(window.currentSearchTerm || '').trim();
|
||
if (livePaging && trimmed !== '') {
|
||
loadFileList(folder, { pane: tablePane, forceLegacy: true, includeContent: window.advancedSearchEnabled === true });
|
||
return;
|
||
}
|
||
if (!livePaging && trimmed === '' && shouldUseServerFilePagingForRequest({})) {
|
||
loadFileList(folder, { pane: tablePane });
|
||
return;
|
||
}
|
||
renderFileTable(folder, container);
|
||
setTimeout(() => {
|
||
const freshInput = document.getElementById("searchInput");
|
||
if (freshInput) {
|
||
freshInput.focus();
|
||
const len = freshInput.value.length;
|
||
freshInput.setSelectionRange(len, len);
|
||
}
|
||
}, 0);
|
||
}, 300));
|
||
}
|
||
|
||
document.querySelectorAll("#fileList table.filr-table thead th[data-column]").forEach(cell => {
|
||
cell.addEventListener("click", function () {
|
||
const column = this.getAttribute("data-column");
|
||
sortFiles(column, folder);
|
||
});
|
||
});
|
||
document.querySelectorAll("#fileList .file-checkbox").forEach(checkbox => {
|
||
checkbox.addEventListener("change", function (e) {
|
||
clearFolderSelections();
|
||
updateRowHighlight(e.target);
|
||
updateFileActionButtons();
|
||
});
|
||
});
|
||
document.querySelectorAll(".share-btn").forEach(btn => {
|
||
btn.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
const fileName = this.getAttribute("data-file");
|
||
const file = fileData.find(f => f.name === fileName);
|
||
if (file) {
|
||
import('./filePreview.js?v={{APP_QVER}}').then(module => {
|
||
module.openShareModal(file, folder);
|
||
});
|
||
}
|
||
});
|
||
});
|
||
bindFolderToolbarActions();
|
||
updateFileActionButtons();
|
||
maybeHighlightSearchedFile(folder);
|
||
|
||
// Dragstart only for file rows (skip folder rows)
|
||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||
if (row.classList.contains("folder-row")) return;
|
||
row.setAttribute("draggable", "true");
|
||
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||
row.addEventListener("dragstart", module.fileDragStartHandler);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
|
||
btn.addEventListener("click", e => e.stopPropagation());
|
||
});
|
||
|
||
// Right-click context menu stays for power users
|
||
bindFileListContextMenu();
|
||
|
||
refreshViewedBadges(folder).catch(() => { });
|
||
}
|
||
|
||
// A helper to compute the max image height based on the current column count.
|
||
function getMaxImageHeight() {
|
||
const columns = parseInt(window.galleryColumns || 3, 10);
|
||
return 150 * (7 - columns);
|
||
}
|
||
|
||
export function renderGalleryView(folder, container) {
|
||
clearInlineRenameState({ restore: false });
|
||
const fileListContent = container || document.getElementById("fileList");
|
||
const galleryPane = getPaneKeyForElement(fileListContent);
|
||
const panePaging = getPaneFileListPaging(galleryPane);
|
||
const paneSourceId = String(getPaneSourceIdForElement(fileListContent) || getActivePaneSourceId() || '');
|
||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||
const serverPagingActive = !!(panePaging && searchTerm === "" && !window.advancedSearchEnabled);
|
||
let filteredFiles = searchFiles(searchTerm);
|
||
|
||
if (!serverPagingActive && Array.isArray(filteredFiles) && filteredFiles.length) {
|
||
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
|
||
}
|
||
|
||
// pagination settings
|
||
const itemsPerPage = window.itemsPerPage;
|
||
let currentPage = serverPagingActive
|
||
? (Number(panePaging.page) || 1)
|
||
: (window.currentPage || 1);
|
||
const totalFiles = filteredFiles.length;
|
||
let totalPages = Math.ceil(totalFiles / itemsPerPage);
|
||
if (serverPagingActive) {
|
||
const pagingTotalPages = Number.isFinite(Number(panePaging.totalPages)) ? Number(panePaging.totalPages) : 1;
|
||
totalPages = Math.max(1, pagingTotalPages || totalPages || 1);
|
||
currentPage = Math.max(1, Math.min(totalPages, Number(panePaging.page) || currentPage));
|
||
window.currentPage = currentPage;
|
||
} else if (currentPage > totalPages) {
|
||
currentPage = totalPages || 1;
|
||
window.currentPage = currentPage;
|
||
}
|
||
// Keep pane-local pagination state in sync for gallery mode as well.
|
||
savePaneState(galleryPane, {
|
||
currentPage,
|
||
currentSearchTerm: window.currentSearchTerm || ''
|
||
});
|
||
|
||
// --- Top controls: search + pagination + items-per-page ---
|
||
let galleryHTML = buildSearchAndPaginationControls({
|
||
currentPage,
|
||
totalPages,
|
||
searchTerm: window.currentSearchTerm || ""
|
||
});
|
||
|
||
// wire up search input just like table view
|
||
setTimeout(() => {
|
||
const searchInput = document.getElementById("searchInput");
|
||
if (searchInput) {
|
||
searchInput.addEventListener("input", debounce(() => {
|
||
window.currentSearchTerm = searchInput.value;
|
||
window.currentPage = 1;
|
||
savePaneState(galleryPane, {
|
||
currentSearchTerm: window.currentSearchTerm || '',
|
||
currentPage: window.currentPage || 1
|
||
});
|
||
const livePaging = getPaneFileListPaging(galleryPane);
|
||
const trimmed = String(window.currentSearchTerm || '').trim();
|
||
if (livePaging && trimmed !== '') {
|
||
loadFileList(folder, { pane: galleryPane, forceLegacy: true, includeContent: window.advancedSearchEnabled === true });
|
||
return;
|
||
}
|
||
if (!livePaging && trimmed === '' && shouldUseServerFilePagingForRequest({})) {
|
||
loadFileList(folder, { pane: galleryPane });
|
||
return;
|
||
}
|
||
renderGalleryView(folder);
|
||
setTimeout(() => {
|
||
const f = document.getElementById("searchInput");
|
||
if (f) {
|
||
f.focus();
|
||
const len = f.value.length;
|
||
f.setSelectionRange(len, len);
|
||
}
|
||
}, 0);
|
||
}, 300));
|
||
}
|
||
}, 0);
|
||
|
||
// determine column max by screen size
|
||
const maxCols = getGalleryMaxColumns();
|
||
const startCols = Math.min(window.galleryColumns || 3, maxCols);
|
||
window.galleryColumns = startCols;
|
||
|
||
// --- Start gallery grid ---
|
||
galleryHTML += `
|
||
<div class="gallery-container"
|
||
style="display:grid;
|
||
grid-template-columns:repeat(${startCols},minmax(0,1fr));
|
||
gap:10px;
|
||
padding:10px;">
|
||
`;
|
||
|
||
// slice current page
|
||
const startIdx = serverPagingActive ? 0 : (currentPage - 1) * itemsPerPage;
|
||
const pageFiles = serverPagingActive
|
||
? filteredFiles
|
||
: filteredFiles.slice(startIdx, startIdx + itemsPerPage);
|
||
const maxImagePreviewBytes = getMaxImagePreviewBytes();
|
||
const maxVideoPreviewBytes = getMaxVideoPreviewBytes();
|
||
|
||
pageFiles.forEach((file, idx) => {
|
||
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
|
||
|
||
const sourceId = String(file.sourceId || paneSourceId || '').trim();
|
||
const previewURL = apiFileUrl(folder, file.name, true, sourceId);
|
||
|
||
// thumbnail
|
||
let thumbnail;
|
||
if (/\.(jpe?g|png|gif|bmp|webp|ico)$/i.test(file.name)) {
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
const tooBig = bytes != null && bytes > maxImagePreviewBytes;
|
||
if (tooBig) {
|
||
const sizeLabel = bytes != null ? formatSize(bytes) : '';
|
||
thumbnail = `
|
||
<div class="gallery-placeholder" style="
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
min-height:${getMaxImageHeight()}px;
|
||
padding:10px;
|
||
border-radius:8px;
|
||
background:rgba(0,0,0,0.05);
|
||
color:#444;
|
||
font-size:0.9rem;
|
||
text-align:center;
|
||
">
|
||
${escapeHTML(t("preview_too_large") || "Preview disabled for large image")}
|
||
${sizeLabel ? `<div style="font-size:0.8rem; opacity:0.75; margin-top:4px;">${escapeHTML(sizeLabel)}</div>` : ""}
|
||
</div>
|
||
`;
|
||
} else {
|
||
const cacheKey = previewURL; // include folder & file
|
||
const src = (window.imageCache && window.imageCache[cacheKey]) || previewURL;
|
||
thumbnail = `<img
|
||
src="${src}"
|
||
class="gallery-thumbnail"
|
||
data-cache-key="${cacheKey}"
|
||
alt="${escapeHTML(file.name)}"
|
||
loading="lazy"
|
||
decoding="async"
|
||
fetchpriority="low"
|
||
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
|
||
}
|
||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
if (bytes != null && bytes > maxVideoPreviewBytes) {
|
||
thumbnail = `<span class="material-icons gallery-icon">movie</span>`;
|
||
} else {
|
||
const maxHeight = getMaxImageHeight();
|
||
const sourceId = String(
|
||
file.sourceId ||
|
||
getPaneSourceIdForElement(container) ||
|
||
getActivePaneSourceId() ||
|
||
""
|
||
).trim();
|
||
const thumbUrl = apiVideoThumbUrl(folder, file.name, sourceId);
|
||
const cacheKey = thumbUrl;
|
||
const src = (window.imageCache && window.imageCache[cacheKey]) || thumbUrl;
|
||
thumbnail = `
|
||
<div class="gallery-video-thumb" style="
|
||
position:relative;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
min-height:${maxHeight}px;
|
||
background:rgba(0,0,0,0.04);
|
||
border-radius:8px;
|
||
">
|
||
<img
|
||
src="${src}"
|
||
class="gallery-thumbnail gallery-video-thumb-img"
|
||
data-cache-key="${cacheKey}"
|
||
alt="${escapeHTML(file.name)}"
|
||
loading="lazy"
|
||
decoding="async"
|
||
fetchpriority="low"
|
||
style="max-width:100%; max-height:${maxHeight}px; display:block; border-radius:8px; pointer-events:none;"
|
||
>
|
||
<span class="material-icons" style="
|
||
position:absolute;
|
||
font-size:32px;
|
||
color:rgba(255,255,255,0.9);
|
||
text-shadow:0 2px 6px rgba(0,0,0,0.5);
|
||
pointer-events:none;
|
||
">play_circle</span>
|
||
</div>
|
||
`;
|
||
}
|
||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
|
||
} else {
|
||
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
|
||
}
|
||
|
||
// tag badges
|
||
let tagBadgesHTML = "";
|
||
if (file.tags && file.tags.length) {
|
||
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
|
||
file.tags.forEach(tag => {
|
||
const safeColor = sanitizeTagColor(tag.color);
|
||
tagBadgesHTML += `<span style="background-color:${safeColor};
|
||
color:#fff;
|
||
padding:2px 4px;
|
||
border-radius:3px;
|
||
margin-right:2px;
|
||
font-size:0.8em;">
|
||
${escapeHTML(tag.name)}
|
||
</span>`;
|
||
});
|
||
tagBadgesHTML += `</div>`;
|
||
}
|
||
|
||
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
|
||
const sizeLabel = bytes != null ? formatSize(bytes) : "";
|
||
const sizeHTML = sizeLabel
|
||
? `<span class="gallery-file-size" style="display:block; font-size:0.8rem; opacity:0.75; font-variant-numeric:tabular-nums;">
|
||
${escapeHTML(sizeLabel)}
|
||
</span>`
|
||
: "";
|
||
|
||
// card with checkbox, preview, info, buttons
|
||
galleryHTML += `
|
||
<div class="gallery-card"
|
||
data-file-name="${escapeHTML(file.name)}"
|
||
style="position:relative; border-radius:12px; border:1px solid #ccc; padding:5px; text-align:center; width:100%; min-width:0; box-sizing:border-box;">
|
||
<input type="checkbox"
|
||
class="file-checkbox"
|
||
id="cb-${idSafe}"
|
||
value="${escapeHTML(file.name)}"
|
||
style="position:absolute; top:5px; left:5px; z-index:10;">
|
||
<label for="cb-${idSafe}"
|
||
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
|
||
|
||
<div class="gallery-preview" style="cursor:pointer;"
|
||
data-preview-url="${previewURL}"
|
||
data-preview-name="${escapeHTML(file.name)}">
|
||
${thumbnail}
|
||
</div>
|
||
|
||
<div class="gallery-info" style="margin-top:5px; min-width:0;">
|
||
<span class="gallery-file-name"
|
||
style="display:block; max-width:100%; white-space:normal; overflow-wrap:anywhere; word-break:break-word;">
|
||
${escapeHTML(file.name)}
|
||
</span>
|
||
${sizeHTML}
|
||
${tagBadgesHTML}
|
||
|
||
<div class="btn-group btn-group-sm btn-group-hover" role="group" aria-label="File actions" style="margin-top:5px;">
|
||
<button
|
||
type="button"
|
||
class="btn btn-success py-1 download-btn"
|
||
data-download-name="${escapeHTML(file.name)}"
|
||
data-download-folder="${file.folder || "root"}"
|
||
title="${t('download')}"
|
||
>
|
||
<i class="material-icons">file_download</i>
|
||
</button>
|
||
|
||
${file.editable ? `
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary py-1 edit-btn"
|
||
data-edit-name="${escapeHTML(file.name)}"
|
||
data-edit-folder="${file.folder || "root"}"
|
||
data-edit-size-bytes="${bytes != null ? String(bytes) : ''}"
|
||
title="${t('edit')}"
|
||
>
|
||
<i class="material-icons">edit</i>
|
||
</button>` : ""}
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-warning py-1 rename-btn"
|
||
data-rename-name="${escapeHTML(file.name)}"
|
||
data-rename-folder="${file.folder || "root"}"
|
||
title="${t('rename')}"
|
||
>
|
||
<i class="material-icons">drive_file_rename_outline</i>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary py-1 share-btn"
|
||
data-file="${escapeHTML(file.name)}"
|
||
title="${t('share')}"
|
||
>
|
||
<i class="material-icons">share</i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
galleryHTML += `</div>`; // end gallery-container
|
||
|
||
// bottom controls
|
||
galleryHTML += buildBottomControls(itemsPerPage);
|
||
|
||
// render
|
||
fileListContent.innerHTML = galleryHTML;
|
||
|
||
|
||
// pagination buttons for gallery
|
||
const prevBtn = document.getElementById("prevPageBtn");
|
||
if (prevBtn) prevBtn.addEventListener("click", () => {
|
||
if (serverPagingActive) {
|
||
const prevCursor = panePaging && panePaging.prevCursor != null
|
||
? String(panePaging.prevCursor)
|
||
: '';
|
||
if (prevCursor !== '') {
|
||
loadFileList(folder, { pane: galleryPane, cursor: prevCursor });
|
||
}
|
||
return;
|
||
}
|
||
if (window.currentPage > 1) {
|
||
window.currentPage--;
|
||
renderGalleryView(folder, container);
|
||
}
|
||
});
|
||
const nextBtn = document.getElementById("nextPageBtn");
|
||
if (nextBtn) nextBtn.addEventListener("click", () => {
|
||
if (serverPagingActive) {
|
||
const nextCursor = panePaging && panePaging.nextCursor != null
|
||
? String(panePaging.nextCursor)
|
||
: '';
|
||
if (nextCursor !== '') {
|
||
loadFileList(folder, { pane: galleryPane, cursor: nextCursor });
|
||
}
|
||
return;
|
||
}
|
||
if (window.currentPage < totalPages) {
|
||
window.currentPage++;
|
||
renderGalleryView(folder, container);
|
||
}
|
||
});
|
||
|
||
// advanced search toggle
|
||
const advToggle = document.getElementById("advancedSearchToggle");
|
||
if (advToggle) advToggle.addEventListener("click", () => {
|
||
toggleAdvancedSearch();
|
||
});
|
||
|
||
// context menu in gallery
|
||
bindFileListContextMenu();
|
||
|
||
// items-per-page selector for gallery
|
||
const itemsSelect = document.getElementById("itemsPerPageSelect");
|
||
if (itemsSelect) itemsSelect.addEventListener("change", e => {
|
||
window.itemsPerPage = parseInt(e.target.value, 10);
|
||
localStorage.setItem("itemsPerPage", window.itemsPerPage);
|
||
window.currentPage = 1;
|
||
if (serverPagingActive) {
|
||
loadFileList(folder, { pane: galleryPane, cursor: "" });
|
||
return;
|
||
}
|
||
renderGalleryView(folder, container);
|
||
});
|
||
|
||
// cache images on load
|
||
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
|
||
const key = img.dataset.cacheKey;
|
||
img.addEventListener('load', () => cacheImage(img, key));
|
||
});
|
||
|
||
// video thumbnail fallback (ffmpeg may be unavailable)
|
||
fileListContent.querySelectorAll('.gallery-video-thumb-img').forEach(img => {
|
||
img.addEventListener('error', () => {
|
||
const wrapper = img.closest('.gallery-video-thumb');
|
||
if (wrapper) wrapper.innerHTML = `<span class="material-icons gallery-icon">movie</span>`;
|
||
}, { once: true });
|
||
});
|
||
|
||
// preview clicks (dynamic import to avoid global dependency)
|
||
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
|
||
el.addEventListener("click", async () => {
|
||
const m = await import('./filePreview.js?v={{APP_QVER}}');
|
||
m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
|
||
});
|
||
});
|
||
|
||
// download clicks
|
||
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
|
||
btn.addEventListener("click", e => {
|
||
e.stopPropagation();
|
||
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
|
||
});
|
||
});
|
||
|
||
// edit clicks
|
||
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
|
||
btn.addEventListener("click", async e => {
|
||
e.stopPropagation();
|
||
const m = await import('./fileEditor.js?v={{APP_QVER}}');
|
||
const rawSize = btn.dataset.editSizeBytes;
|
||
const sizeNum = (rawSize === undefined || rawSize === '') ? NaN : Number(rawSize);
|
||
const sizeArg = Number.isFinite(sizeNum) ? sizeNum : null;
|
||
m.editFile(btn.dataset.editName, btn.dataset.editFolder, paneSourceId, sizeArg);
|
||
});
|
||
});
|
||
|
||
// rename clicks
|
||
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
|
||
btn.addEventListener("click", async e => {
|
||
e.stopPropagation();
|
||
const m = await import('./fileActions.js?v={{APP_QVER}}');
|
||
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
|
||
});
|
||
});
|
||
|
||
// share clicks
|
||
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
|
||
btn.addEventListener("click", e => {
|
||
e.stopPropagation();
|
||
const fileName = btn.dataset.file;
|
||
const fileObj = fileData.find(f => f.name === fileName);
|
||
if (fileObj) {
|
||
import('./filePreview.js?v={{APP_QVER}}').then(m => m.openShareModal(fileObj, folder));
|
||
}
|
||
});
|
||
});
|
||
|
||
// checkboxes
|
||
fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
|
||
cb.addEventListener("change", () => updateFileActionButtons());
|
||
});
|
||
|
||
// pagination helpers
|
||
window.changePage = newPage => {
|
||
if (serverPagingActive) {
|
||
const target = parseInt(newPage, 10);
|
||
if (target === currentPage - 1 && panePaging && panePaging.prevCursor != null) {
|
||
const prevCursor = String(panePaging.prevCursor);
|
||
if (prevCursor !== '') {
|
||
loadFileList(folder, { pane: galleryPane, cursor: prevCursor });
|
||
}
|
||
} else if (target === currentPage + 1 && panePaging && panePaging.nextCursor != null) {
|
||
const nextCursor = String(panePaging.nextCursor);
|
||
if (nextCursor !== '') {
|
||
loadFileList(folder, { pane: galleryPane, cursor: nextCursor });
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
window.currentPage = newPage;
|
||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||
else renderFileTable(folder);
|
||
};
|
||
|
||
window.changeItemsPerPage = cnt => {
|
||
window.itemsPerPage = +cnt;
|
||
localStorage.setItem("itemsPerPage", cnt);
|
||
window.currentPage = 1;
|
||
if (serverPagingActive) {
|
||
loadFileList(folder, { pane: galleryPane, cursor: "" });
|
||
return;
|
||
}
|
||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||
else renderFileTable(folder);
|
||
};
|
||
refreshViewedBadges(folder).catch(() => { });
|
||
bindFolderToolbarActions();
|
||
updateFileActionButtons();
|
||
createViewToggleButton();
|
||
ensureSearchEverywhereButton();
|
||
bindViewOptionsButton();
|
||
maybeHighlightSearchedFile(folder);
|
||
}
|
||
|
||
/**
|
||
* Fallback: derive selected files from DOM checkboxes if no explicit list
|
||
* of file objects is provided.
|
||
*/
|
||
function getSelectedFilesForDownload() {
|
||
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
|
||
if (!checks.length) return [];
|
||
|
||
// checkbox values are ESCAPED names
|
||
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
|
||
if (!selectedEsc.length) return [];
|
||
|
||
const escSet = new Set(selectedEsc);
|
||
|
||
const files = Array.isArray(fileData)
|
||
? fileData.filter(f => escSet.has(escapeHTML(f.name)))
|
||
: [];
|
||
|
||
return files.map(f => ({
|
||
folder: f.folder || window.currentFolder || 'root',
|
||
name: f.name
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Push selected files into a stepper queue and show the
|
||
* bottom-right panel with "Download next / Cancel".
|
||
*
|
||
* Expects `fileObjs` to be an array of file objects from `fileData`
|
||
* (e.g. currentSelection().files in fileMenu.js).
|
||
*/
|
||
export function downloadSelectedFilesIndividually(fileObjs) {
|
||
const src = Array.isArray(fileObjs) ? fileObjs : [];
|
||
|
||
if (!src.length) {
|
||
showToast(t('no_files_selected') || 'No files selected.', 'warning');
|
||
return;
|
||
}
|
||
|
||
const mapped = src.map(f => ({
|
||
folder: f.folder || window.currentFolder || 'root',
|
||
name: f.name,
|
||
sourceId: String(f.sourceId || getActivePaneSourceId() || '').trim()
|
||
}));
|
||
|
||
const limit = window.maxNonZipDownloads || MAX_NONZIP_MULTI_DOWNLOAD;
|
||
if (mapped.length > limit) {
|
||
const inEncrypted = !!(window.currentFolderCaps && window.currentFolderCaps.encryption && window.currentFolderCaps.encryption.encrypted);
|
||
const msg = inEncrypted
|
||
? `You selected ${mapped.length} files. In encrypted folders, downloads are limited to ${limit} files at a time.`
|
||
: (t('too_many_plain_downloads', { count: mapped.length, limit }) || `You selected ${mapped.length} files. For more than ${limit} files, please use "Download as Archive".`);
|
||
showToast(msg, 'warning');
|
||
return;
|
||
}
|
||
|
||
// Replace any existing queue with the new one.
|
||
window.__nonZipDownloadQueue = mapped.slice();
|
||
|
||
// Show the panel; user will click "Download next" for each file.
|
||
showNonZipPanel();
|
||
|
||
// auto-fire the first file here:
|
||
triggerNextNonZipDownload();
|
||
}
|
||
|
||
function compareFilesForSort(a, b) {
|
||
const column = sortOrder?.column || "uploaded";
|
||
const ascending = sortOrder?.ascending !== false;
|
||
|
||
let valA = a[column] ?? "";
|
||
let valB = b[column] ?? "";
|
||
|
||
if (column === "size" || column === "filesize") {
|
||
// numeric size
|
||
valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0;
|
||
valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0;
|
||
} else if (column === "modified" || column === "uploaded") {
|
||
// date sort (newest/oldest)
|
||
const parsedA = parseCustomDate(String(valA || ""));
|
||
const parsedB = parseCustomDate(String(valB || ""));
|
||
valA = parsedA;
|
||
valB = parsedB;
|
||
} else {
|
||
if (typeof valA === "string") valA = valA.toLowerCase();
|
||
if (typeof valB === "string") valB = valB.toLowerCase();
|
||
}
|
||
|
||
if (valA < valB) return ascending ? -1 : 1;
|
||
if (valA > valB) return ascending ? 1 : -1;
|
||
return 0;
|
||
}
|
||
|
||
function maybeHighlightSearchedFile(folder) {
|
||
if (!pendingSearchSelection) return;
|
||
const target = pendingSearchSelection;
|
||
if (!target || target.folder !== folder) return;
|
||
const name = target.name;
|
||
if (!name) return;
|
||
|
||
const findRow = () => {
|
||
const nodes = document.querySelectorAll('#fileList tr[data-file-name], .gallery-card[data-file-name]');
|
||
for (const row of nodes) {
|
||
const raw = row.getAttribute('data-file-name') || '';
|
||
const decoded = decodeHtmlEntities(raw);
|
||
if (raw === name || decoded === name) return row;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
try {
|
||
const row = findRow();
|
||
if (!row) {
|
||
if (typeof target.retries !== 'number') target.retries = 10;
|
||
if (target.retries > 0) {
|
||
target.retries -= 1;
|
||
// Try again shortly in case the DOM finishes rendering
|
||
setTimeout(() => maybeHighlightSearchedFile(folder), 120);
|
||
}
|
||
pendingSearchSelection = target;
|
||
return;
|
||
}
|
||
|
||
const alreadySelected = row.classList.contains('row-selected') || row.classList.contains('selected');
|
||
|
||
// Select checkbox if present
|
||
const cb = row.querySelector('.file-checkbox');
|
||
if (cb && !alreadySelected) {
|
||
// clear other selections
|
||
document.querySelectorAll('#fileList .file-checkbox').forEach(box => {
|
||
if (box !== cb) {
|
||
box.checked = false;
|
||
updateRowHighlight(box);
|
||
}
|
||
});
|
||
cb.checked = true;
|
||
updateRowHighlight(cb);
|
||
updateFileActionButtons();
|
||
} else if (!cb && !alreadySelected) {
|
||
row.classList.add('row-selected', 'selected');
|
||
}
|
||
|
||
if (!alreadySelected) {
|
||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
if (!target.clearTimer) {
|
||
const ref = target;
|
||
target.clearTimer = setTimeout(() => {
|
||
if (pendingSearchSelection === ref) {
|
||
pendingSearchSelection = null;
|
||
}
|
||
}, 900);
|
||
}
|
||
} catch (e) {
|
||
// best-effort highlight only; leave selection pending for a future render
|
||
}
|
||
}
|
||
|
||
|
||
export function sortFiles(column, folder) {
|
||
if (sortOrder.column === column) {
|
||
sortOrder.ascending = !sortOrder.ascending;
|
||
} else {
|
||
sortOrder.column = column;
|
||
sortOrder.ascending = true;
|
||
}
|
||
|
||
const pane = normalizePaneKey(window.activePane);
|
||
const paging = getPaneFileListPaging(pane);
|
||
const hasSearch = String(window.currentSearchTerm || '').trim() !== '';
|
||
if (paging && !hasSearch && !window.advancedSearchEnabled) {
|
||
window.currentPage = 1;
|
||
savePaneState(pane, { currentPage: 1 });
|
||
loadFileList(folder || window.currentFolder || 'root', { pane, cursor: '' });
|
||
return;
|
||
}
|
||
|
||
// Re-sort master fileData
|
||
fileData.sort(compareFilesForSort);
|
||
|
||
if (window.viewMode === "gallery") {
|
||
renderGalleryView(folder);
|
||
} else {
|
||
renderFileTable(folder);
|
||
}
|
||
}
|
||
|
||
function parseCustomDate(dateStr) {
|
||
dateStr = dateStr.replace(/\s+/g, " ").trim();
|
||
const parts = dateStr.split(" ");
|
||
if (parts.length !== 2) {
|
||
return new Date(dateStr).getTime();
|
||
}
|
||
const datePart = parts[0];
|
||
const timePart = parts[1];
|
||
const dateComponents = datePart.split("/");
|
||
if (dateComponents.length !== 3) {
|
||
return new Date(dateStr).getTime();
|
||
}
|
||
let month = parseInt(dateComponents[0], 10);
|
||
let day = parseInt(dateComponents[1], 10);
|
||
let year = parseInt(dateComponents[2], 10);
|
||
if (year < 100) {
|
||
year += 2000;
|
||
}
|
||
const timeRegex = /^(\d{1,2}):(\d{2})(AM|PM)$/i;
|
||
const match = timePart.match(timeRegex);
|
||
if (!match) {
|
||
return new Date(dateStr).getTime();
|
||
}
|
||
let hour = parseInt(match[1], 10);
|
||
const minute = parseInt(match[2], 10);
|
||
const period = match[3].toUpperCase();
|
||
if (period === "PM" && hour !== 12) {
|
||
hour += 12;
|
||
}
|
||
if (period === "AM" && hour === 12) {
|
||
hour = 0;
|
||
}
|
||
return new Date(year, month - 1, day, hour, minute).getTime();
|
||
}
|
||
|
||
export function canEditFile(fileName) {
|
||
if (!fileName || typeof fileName !== "string") return false;
|
||
const dot = fileName.lastIndexOf(".");
|
||
if (dot < 0) return false;
|
||
const ext = fileName.slice(dot + 1).toLowerCase();
|
||
|
||
// Your CodeMirror text-based types
|
||
const textEditExts = new Set([
|
||
"txt", "text", "md", "markdown", "rst",
|
||
"html", "htm", "xhtml", "shtml",
|
||
"css", "scss", "sass", "less",
|
||
"js", "mjs", "cjs", "jsx",
|
||
"ts", "tsx",
|
||
"json", "jsonc", "ndjson",
|
||
"yml", "yaml", "toml", "xml", "plist",
|
||
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
|
||
"env", "dotenv",
|
||
"csv", "tsv", "tab",
|
||
"log",
|
||
"sh", "bash", "zsh", "ksh", "fish",
|
||
"bat", "cmd",
|
||
"ps1", "psm1", "psd1",
|
||
"py", "pyw", "rb", "pl", "pm", "go", "rs", "java", "kt", "kts",
|
||
"scala", "sc", "groovy", "gradle",
|
||
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
|
||
"m", "mm", "swift", "cs", "fs", "fsx", "dart", "lua", "r", "rmd",
|
||
"sql", "vue", "svelte", "twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
|
||
]);
|
||
|
||
if (textEditExts.has(ext)) return true; // CodeMirror
|
||
if (OO_ENABLED && OO_EXTS.has(ext)) return true; // ONLYOFFICE types if enabled
|
||
return false;
|
||
}
|
||
|
||
// Expose global functions for pagination and preview.
|
||
window.changePage = function (newPage) {
|
||
const pane = normalizePaneKey(window.activePane);
|
||
const paging = getPaneFileListPaging(pane);
|
||
const target = parseInt(newPage, 10);
|
||
if (paging && Number.isFinite(target)) {
|
||
const folderKey = String(window.currentFolder || 'root');
|
||
if (window.viewMode === 'table' && window.showInlineFolders !== false) {
|
||
const sourceId = String(getPaneSourceId(pane) || getGlobalActiveSourceId() || '');
|
||
const subfolders = getSubfoldersForPagingContext(pane, folderKey, sourceId);
|
||
const totalFolders = Array.isArray(subfolders) ? subfolders.length : 0;
|
||
const totalFiles = Number.isFinite(Number(paging.total)) ? Math.max(0, Number(paging.total)) : 0;
|
||
const layout = getCombinedPageLayout({
|
||
totalFolders,
|
||
totalFiles,
|
||
itemsPerPage: getFileListCursorPageSize(),
|
||
targetPage: target
|
||
});
|
||
|
||
window.currentPage = layout.page;
|
||
savePaneState(pane, { currentPage: layout.page });
|
||
|
||
if (layout.fileCount <= 0) {
|
||
renderFileTable(folderKey);
|
||
return;
|
||
}
|
||
|
||
const targetCursor = String(layout.fileStart);
|
||
const currentCursor = String((paging.cursor == null) ? '' : paging.cursor);
|
||
const cursorOffset = parseCursorOffset(currentCursor);
|
||
const loadedCount = Array.isArray(fileData) ? fileData.length : 0;
|
||
const loadedEnd = cursorOffset + Math.max(0, loadedCount);
|
||
const requiredEnd = layout.fileStart + layout.fileCount;
|
||
const hasRange = cursorOffset <= layout.fileStart && loadedEnd >= requiredEnd;
|
||
|
||
if (currentCursor === targetCursor && hasRange) {
|
||
renderFileTable(folderKey);
|
||
return;
|
||
}
|
||
|
||
loadFileList(folderKey, { pane, cursor: targetCursor });
|
||
return;
|
||
}
|
||
|
||
const pageSize = Number.isFinite(Number(paging.limit))
|
||
? Math.max(1, Number(paging.limit))
|
||
: getFileListCursorPageSize();
|
||
const totalPages = Number.isFinite(Number(paging.totalPages))
|
||
? Math.max(1, Number(paging.totalPages))
|
||
: Math.max(1, Math.ceil((Number(paging.total) || 0) / pageSize));
|
||
const clampedTarget = Math.max(1, Math.min(totalPages, target));
|
||
const targetCursor = String(Math.max(0, (clampedTarget - 1) * pageSize));
|
||
const currentCursor = String((paging.cursor == null) ? '' : paging.cursor);
|
||
if (targetCursor === currentCursor) {
|
||
if (window.viewMode === 'gallery') {
|
||
renderGalleryView(folderKey);
|
||
} else {
|
||
renderFileTable(folderKey);
|
||
}
|
||
return;
|
||
}
|
||
loadFileList(folderKey, { pane, cursor: targetCursor });
|
||
return;
|
||
}
|
||
window.currentPage = newPage;
|
||
if (window.viewMode === 'gallery') {
|
||
renderGalleryView(window.currentFolder);
|
||
} else {
|
||
renderFileTable(window.currentFolder);
|
||
}
|
||
};
|
||
|
||
window.changeItemsPerPage = function (newCount) {
|
||
window.itemsPerPage = parseInt(newCount, 10);
|
||
localStorage.setItem('itemsPerPage', newCount);
|
||
window.currentPage = 1;
|
||
const pane = normalizePaneKey(window.activePane);
|
||
savePaneState(pane, { currentPage: 1 });
|
||
const paging = getPaneFileListPaging(pane);
|
||
if (paging) {
|
||
loadFileList(window.currentFolder || 'root', { pane, cursor: '' });
|
||
return;
|
||
}
|
||
if (window.viewMode === 'gallery') {
|
||
renderGalleryView(window.currentFolder);
|
||
} else {
|
||
renderFileTable(window.currentFolder);
|
||
}
|
||
};
|
||
|
||
// fileListView.js (bottom)
|
||
window.loadFileList = loadFileList;
|
||
window.renderFileTable = renderFileTable;
|
||
window.renderGalleryView = renderGalleryView;
|
||
window.sortFiles = sortFiles;
|
||
window.toggleAdvancedSearch = toggleAdvancedSearch;
|
||
window.downloadSelectedFilesIndividually = downloadSelectedFilesIndividually;
|