// fileListView.js
import {
escapeHTML,
debounce,
buildSearchAndPaginationControls,
buildFileTableHeader,
buildFileTableRow,
buildBottomControls,
updateFileActionButtons,
showToast,
updateRowHighlight,
toggleRowSelection,
attachEnterKeyListener
} from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { bindFileListContextMenu } from './fileMenu.js?v={{APP_QVER}}';
import { openDownloadModal } from './fileActions.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import {
getParentFolder,
updateBreadcrumbTitle,
setupBreadcrumbDelegation,
showFolderManagerContextMenu,
hideFolderManagerContextMenu,
openRenameFolderModal,
openDeleteFolderModal,
refreshFolderIcon,
openColorFolderModal,
openMoveFolderUI,
folderSVG
} from './folderManager.js?v={{APP_QVER}}';
import { openFolderShareModal } from './folderShareModal.js?v={{APP_QVER}}';
import {
folderDragOverHandler,
folderDragLeaveHandler,
folderDropHandler
} from './fileDragDrop.js?v={{APP_QVER}}';
export let fileData = [];
export let sortOrder = { column: "modified", ascending: false };
const FOLDER_STRIP_PAGE_SIZE = 50;
// onnlyoffice
let OO_ENABLED = false;
let OO_EXTS = new Set();
export async function initOnlyOfficeCaps() {
try {
const r = await fetch('/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 {
OO_ENABLED = false;
OO_EXTS = new Set();
}
}
function wireFolderStripItems(strip) {
if (!strip) return;
// 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;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
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", e => {
e.preventDefault();
e.stopPropagation();
const dest = el.dataset.folder;
if (!dest) return;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
strip.querySelectorAll(".folder-item.selected")
.forEach(i => i.classList.remove("selected"));
el.classList.add("selected");
const menuItems = [
{
label: t("create_folder"),
action: () => document.getElementById("createFolderModal").style.display = "block"
},
{
label: t("move_folder"),
action: () => openMoveFolderUI()
},
{
label: t("rename_folder"),
action: () => openRenameFolderModal()
},
{
label: t("color_folder"),
action: () => openColorFolderModal(dest)
},
{
label: t("folder_share"),
action: () => openFolderShareModal(dest)
},
{
label: t("delete_folder"),
action: () => openDeleteFolderModal()
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
});
// Close menu when clicking elsewhere
document.addEventListener("click", hideFolderManagerContextMenu);
// Folder icons
strip.querySelectorAll(".folder-item").forEach(el => {
const full = el.getAttribute('data-folder');
if (full) attachStripIconAsync(el, full, 48);
});
}
function renderFolderStripPaged(strip, subfolders) {
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);
let html = visible.map(sf => `
`).join("");
if (endIdx < total) {
html += `
${t('load_more_folders') || t('load_more') || 'Load more folders'}
`;
}
strip.innerHTML = html;
applyFolderStripLayout(strip);
wireFolderStripItems(strip);
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);
}
const TEXT_PREVIEW_MAX_BYTES = 120 * 1024; // ~120 KB
const _fileSnippetCache = new Map();
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}`;
if (!canEditFile(file.name)) {
// No text preview possible for this type – cache the fact and bail
_fileSnippetCache.set(key, "");
return;
}
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
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;
}
// Use cache if we have it
if (_fileSnippetCache.has(key)) {
const cached = _fileSnippetCache.get(key);
if (cached) {
snippetEl.textContent = cached;
snippetEl.style.display = "block";
}
return;
}
snippetEl.style.display = "block";
snippetEl.textContent = t("loading") || "Loading...";
try {
const url = apiFileUrl(folder, file.name, true);
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; // ← per-line cap (tweak to taste)
const allLines = text.split(/\r?\n/);
// Take the first few lines and trim each so they don't wrap forever
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");
// Also enforce an overall character ceiling just in case
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 {
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 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 {}
hoverPreviewActiveRow = null;
hoverPreviewContext = null;
hoverPreviewHoveringCard = false;
if (hoverPreviewEl) {
hoverPreviewEl.style.display = 'none';
}
}
function isHoverPreviewDisabled() {
// Live flag from user panel
if (window.disableHoverPreview === true) return true;
// Fallback to localStorage (e.g. on first page load)
try {
return localStorage.getItem('disableHoverPreview') === 'true';
} catch {
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 = `
`;
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) {
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch {}
updateBreadcrumbTitle(dest);
loadFileList(dest);
}
}
});
return el;
}
function hideHoverPreview() {
cancelHoverPreview();
}
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);
});
// 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 { }
// 3) Repaint any inline folder rows in the file table
try {
const safeFolder = CSS.escape(folder);
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);
});
} catch {
// 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
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 = 0;
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 || [];
// 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 {
// if localStorage blows up, fall back to both enabled
window.showFoldersInList = true;
window.showInlineFolders = true;
}
// Global flag for advanced search mode.
window.advancedSearchEnabled = false;
// --- Folder stats cache (for isEmpty.php) ---
const _folderStatsCache = new Map();
function fetchFolderStats(folder) {
if (!folder) return Promise.resolve(null);
if (_folderStatsCache.has(folder)) {
return _folderStatsCache.get(folder);
}
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`;
const p = _fetchJSONWithTimeout(url, 2500)
.catch(() => ({ folders: 0, files: 0 }))
.finally(() => {
// keep the resolved value; the Promise itself stays in the map
});
_folderStatsCache.set(folder, p);
return p;
}
// --- Folder "peek" cache (first few child folders/files) ---
const FOLDER_PEEK_MAX_ITEMS = 6;
const _folderPeekCache = new Map();
/**
* 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) {
if (!folder) return null;
if (_folderPeekCache.has(folder)) {
return _folderPeekCache.get(folder);
}
const p = (async () => {
try {
// 1) Files in this folder
let files = [];
try {
const res = await fetch(
`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=0&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 {
// ignore file errors; we can still show folders
}
// 2) Direct subfolders
let subfolderNames = [];
try {
const res2 = await fetch(
`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
{ 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 {
// 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 {
return null;
}
})();
_folderPeekCache.set(folder, p);
return p;
}
/* ===========================================================
SECURITY: build file URLs only via the API (no /uploads)
=========================================================== */
function apiFileUrl(folder, name, inline = false) {
const fParam = folder && folder !== "root" ? folder : "root";
const q = new URLSearchParams({
folder: fParam,
file: name,
inline: inline ? "1" : "0"
});
// 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 { /* best-effort only */ }
return `/api/file/download.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 selectAll = 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]'
);
if (!selectAll) return;
const getRowCbs = () =>
Array.from(fileListContent.querySelectorAll('tbody .file-checkbox'))
.filter(cb => !cb.disabled);
// Toggle all rows when the header checkbox changes
selectAll.addEventListener('change', () => {
const checked = selectAll.checked;
getRowCbs().forEach(cb => {
cb.checked = checked;
updateRowHighlight(cb);
});
updateFileActionButtons();
// No indeterminate state when explicitly toggled
selectAll.indeterminate = false;
});
// Keep header checkbox state in sync with row selections
const syncHeader = () => {
const cbs = getRowCbs();
const total = cbs.length;
const checked = cbs.filter(cb => cb.checked).length;
if (!total) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
selectAll.checked = checked === total;
selectAll.indeterminate = checked > 0 && checked < total;
};
// Listen for any row checkbox changes to refresh header state
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(`
folder
${t("folder") || "Folder"}
`);
props.push(`
${t("path") || "Path"}: ${escapeHTML(folderPath || "root")}
`);
propsEl.innerHTML = props.join("");
// --- Owner + "Your access" (from capabilities) --------------------
fetchFolderCaps(folderPath).then(caps => {
if (!caps || !document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
const owner = caps.owner || caps.user || "";
if (owner) {
props.push(`
${t("owner") || "Owner"}: ${escapeHTML(owner)}
`);
}
// 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(`
${escapeHTML(label)}: ${escapeHTML(perms.join(", "))}
`);
}
propsEl.innerHTML = props.join("");
}).catch(() => {});
// ------------------------------------------------------------------
// --- Meta: counts + size + created/modified -----------------------
fetchFolderStats(folderPath).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(`
${t("modified") || "Modified"}: ${escapeHTML(modified)}
`);
}
if (created) {
props.push(`
${t("created") || "Created"}: ${escapeHTML(created)}
`);
}
propsEl.innerHTML = props.join("");
}).catch(() => {});
// ------------------------------------------------------------------
// Left side: peek inside folder (first few children)
fetchFolderPeek(folderPath).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 file = Array.isArray(fileData)
? fileData.find(f => f.name === name)
: null;
// 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 url = apiFileUrl(folder, file.name, true);
const canTextPreview = canEditFile(file.name);
// Left: image preview OR text snippet OR "No preview"
if (isImage) {
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);
}
// Icon type for right column
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(`
${iconName}
${escapeHTML(ext || "").toUpperCase() || t("file") || "File"}
`);
if (ext) {
props.push(`${t("extension") || "Ext"}: .${escapeHTML(ext)}
`);
}
if (Number.isFinite(file.sizeBytes) && file.sizeBytes >= 0) {
const prettySize = formatSize(file.sizeBytes);
props.push(`
${t("size") || "Size"}:
${escapeHTML(prettySize)}
`);
}
if (file.modified) {
props.push(`${t("modified") || "Modified"}: ${escapeHTML(file.modified)}
`);
}
if (file.uploaded) {
props.push(`${t("created") || "Created"}: ${escapeHTML(file.uploaded)}
`);
}
if (file.uploader) {
props.push(`${t("owner") || "Owner"}: ${escapeHTML(file.uploader)}
`);
}
// --- 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(
`${escapeHTML(label)}: ${escapeHTML(parts.join(" • "))}
`
);
})();
// ------------------------------------------------------------------
propsEl.innerHTML = props.join("");
propsEl.innerHTML = props.join("");
// Text snippet (left) for smaller text/code files
if (canTextPreview) {
fillFileSnippet(file, snippetEl);
} else if (!isImage) {
// Non-image, non-text → explicit "No preview"
const msg = t("no_preview_available") || "No preview available";
thumbEl.innerHTML = `
${escapeHTML(msg)}
`;
}
}
}
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)); }
// 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 })
.catch(() => ({ folders: 0, files: 0 }))
.finally(() => clearTimeout(tid));
}
// Paint initial icon, then flip to "paper" if non-empty
function attachStripIconAsync(hostEl, fullPath, size = 28) {
const hex = (window.folderColorMap && window.folderColorMap[fullPath]) || '#f6b84e';
const front = hex;
const back = _lighten(hex, 14);
const stroke = _darken(hex, 22);
hostEl.style.setProperty('--filr-folder-front', front);
hostEl.style.setProperty('--filr-folder-back', back);
hostEl.style.setProperty('--filr-folder-stroke', stroke);
const iconSpan = hostEl.querySelector('.folder-svg');
if (!iconSpan) return;
// 1) initial "empty" icon
iconSpan.dataset.kind = 'empty';
iconSpan.innerHTML = folderSVG('empty');
// make sure this brand-new SVG is sized correctly
try { syncFolderIconSizeToRowHeight(); } catch {}
fetchFolderStats(fullPath)
.then(stats => {
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');
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.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 { /* 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 ?? {};
}
// --- Folder capabilities + owner cache ----------------------
const _folderCapsCache = new Map();
async function fetchFolderCaps(folder) {
if (!folder) return null;
if (_folderCapsCache.has(folder)) {
return _folderCapsCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
_folderCapsCache.set(folder, data || null);
if (data && (data.owner || data.user)) {
_folderOwnerCache.set(folder, data.owner || data.user || "");
}
return data || null;
} catch {
_folderCapsCache.set(folder, null);
return null;
}
}
// --- Folder owner cache + helper ----------------------
const _folderOwnerCache = new Map();
async function fetchFolderOwner(folder) {
if (!folder) return "";
if (_folderOwnerCache.has(folder)) {
return _folderOwnerCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
const owner = data && (data.owner || data.user || "");
_folderOwnerCache.set(folder, owner || "");
return owner || "";
} catch {
_folderOwnerCache.set(folder, "");
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(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`, { credentials: 'include' });
const j = await res.json();
map = j?.map || null;
} catch { /* 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 = `
${t('cancel') || 'Cancel'}
${t('download_next') || 'Download next'}
`;
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 } = q.shift();
const url = apiFileUrl(folder || 'root', name, /* inline */ false);
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;
/**
* Build the folder summary HTML using the filtered file list.
*/
function buildFolderSummary(filteredFiles) {
const totalFiles = filteredFiles.length;
const totalBytes = filteredFiles.reduce((sum, file) => {
return sum + parseSizeToBytes(file.size);
}, 0);
const sizeStr = formatSize(totalBytes);
return `${t('total_files')}: ${totalFiles} | ${t('total_size')}: ${sizeStr}`;
}
/**
* Advanced Search toggle
*/
function toggleAdvancedSearch() {
window.advancedSearchEnabled = !window.advancedSearchEnabled;
const advancedBtn = document.getElementById("advancedSearchToggle");
if (advancedBtn) {
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
}
renderFileTable(window.currentFolder);
}
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 = '/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}';
let _fuseLoadingPromise = null;
function loadScriptOnce(src) {
// cache by src so we don't append multiple