// 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: "uploaded", ascending: true }; 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); } // 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); } 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; // 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; /* =========================================================== SECURITY: build file URLs only via the API (no /uploads) =========================================================== */ function apiFileUrl(folder, name, inline = false) { const f = folder && folder !== "root" ? folder : "root"; const q = new URLSearchParams({ folder: f, file: name, inline: inline ? "1" : "0", t: String(Date.now()) // cache-bust }); 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(); } // ---- 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 {} const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`; _fetchJSONWithTimeout(url, 2500) .then(({ folders = 0, files = 0 }) => { if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') { // 2) swap to "paper" icon iconSpan.dataset.kind = 'paper'; iconSpan.innerHTML = folderSVG('paper'); // re-apply sizing to this new SVG too try { syncFolderIconSizeToRowHeight(); } catch {} } }) .catch(() => { /* ignore */ }); } /* ----------------------------- 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 (totalBytes < 1024) { return totalBytes + " Bytes"; } else if (totalBytes < 1024 * 1024) { return (totalBytes / 1024).toFixed(2) + " KB"; } else if (totalBytes < 1024 * 1024 * 1024) { return (totalBytes / (1024 * 1024)).toFixed(2) + " MB"; } else { return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; } } /** * 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