// 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 => `
`).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