// 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 => `
${escapeHTML(sf.name)}
`).join(""); if (endIdx < total) { html += ` `; } 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 = `
`; 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