diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 8a8abd6..726f64c 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -10,6 +10,8 @@ let fileUploads = new Map(); let fileUploadsById = new Map(); let activeTransfers = new Map(); let currentEditingFile = null; +let currentPreviewBlobUrl = null; +let currentPreviewPath = null; let lastSuccessfulResponse = 0; let pendingToast = null; const recentToasts = new Map(); @@ -69,6 +71,12 @@ const editorStatus = document.getElementById("editor-status"); const editorSaveBtn = document.getElementById("editor-save-btn"); const editorCancelBtn = document.getElementById("editor-cancel-btn"); const editorCloseBtn = document.getElementById("editor-close-btn"); +const filePreviewModal = document.getElementById("file-preview-modal"); +const previewFileNameEl = document.getElementById("preview-file-name"); +const previewContent = document.getElementById("preview-content"); +const previewCloseBtn = document.getElementById("preview-close-btn"); +const previewDownloadBtn = document.getElementById("preview-download-btn"); +const previewStatusEl = document.getElementById("preview-status"); if (clientIdHeader) { clientIdHeader.textContent = `${clientId} - File Browser`; @@ -346,6 +354,163 @@ function getFileExt(name = "") { return name.slice(idx + 1).toLowerCase(); } + +const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]); +const PREVIEW_PDF_EXTS = new Set(["pdf"]); +const PREVIEW_MAX_BYTES = 50 * 1024 * 1024; + +// list of binary files from chatgpt (I can't think of anything else either) +const KNOWN_BINARY_EXTS = new Set([ + "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", + "mp3", "wav", "flac", "ogg", "aac", "wma", "m4a", + "exe", "msi", "com", "app", "appimage", + "dll", "so", "dylib", "lib", + "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz", + "db", "sqlite", "sqlite3", "mdb", + "ttf", "otf", "woff", "woff2", "eot", + "iso", "img", "vhd", "vmdk", +]); + +const IMAGE_MIME_MAP = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", +}; + +function isPreviewable(name) { + const ext = getFileExt(name); + return PREVIEW_IMAGE_EXTS.has(ext) || PREVIEW_PDF_EXTS.has(ext); +} + +function getPreviewMimeType(name) { + const ext = getFileExt(name); + if (PREVIEW_PDF_EXTS.has(ext)) return "application/pdf"; + return IMAGE_MIME_MAP[ext] || null; +} + +function closePreview() { + if (filePreviewModal) filePreviewModal.classList.remove("show"); + if (currentPreviewBlobUrl) { + URL.revokeObjectURL(currentPreviewBlobUrl); + currentPreviewBlobUrl = null; + } + if (previewContent) previewContent.innerHTML = ""; + if (previewStatusEl) previewStatusEl.textContent = ""; + currentPreviewPath = null; +} + +async function openFilePreview(path, knownSize) { + const fileName = path.split(/[\/\\]/).pop() || ""; + const mimeType = getPreviewMimeType(fileName); + if (!mimeType) return; + + if (typeof knownSize === "number" && knownSize > PREVIEW_MAX_BYTES) { + notifyToast(`File too large to preview (${formatBytes(knownSize)})`, "info", 4000); + return; + } + + currentPreviewPath = path; + if (previewFileNameEl) previewFileNameEl.textContent = fileName; + if (previewContent) previewContent.innerHTML = ''; + if (previewStatusEl) previewStatusEl.textContent = "Loading..."; + if (filePreviewModal) filePreviewModal.classList.add("show"); + + try { + const requestRes = await fetch("/api/file/download/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ clientId, path }), + }); + + if (!requestRes.ok) { + const text = await requestRes.text(); + if (currentPreviewPath !== path) return; + if (previewContent) previewContent.innerHTML = `
${escapeHtml(text || "Failed to load preview")}
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + return; + } + + const requestData = await requestRes.json(); + const downloadUrl = typeof requestData?.downloadUrl === "string" + ? requestData.downloadUrl + : (requestData?.downloadId + ? `/api/file/download/${encodeURIComponent(requestData.downloadId)}` + : ""); + + if (!downloadUrl) { + if (currentPreviewPath !== path) return; + if (previewContent) previewContent.innerHTML = `
Failed to load preview
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + return; + } + + const res = await fetch(downloadUrl, { + method: "GET", + credentials: "include", + }); + + if (!res.ok) { + const text = await res.text(); + if (currentPreviewPath !== path) return; + if (previewContent) previewContent.innerHTML = `
${escapeHtml(text || "Failed to load preview")}
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + return; + } + + // Reject oversized files before buffering to prevent OOM. + const contentLength = Number(res.headers.get("Content-Length") || 0); + if (contentLength > PREVIEW_MAX_BYTES) { + if (currentPreviewPath !== path) return; + if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(contentLength))})
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + return; + } + + const arrayBuffer = await res.arrayBuffer(); + + if (currentPreviewPath !== path) return; + + if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) { + if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + return; + } + + if (currentPreviewBlobUrl) { + URL.revokeObjectURL(currentPreviewBlobUrl); + currentPreviewBlobUrl = null; + } + + const blob = new Blob([arrayBuffer], { type: mimeType }); + currentPreviewBlobUrl = URL.createObjectURL(blob); + + if (previewContent) { + previewContent.innerHTML = ""; + if (PREVIEW_IMAGE_EXTS.has(getFileExt(fileName))) { + const img = document.createElement("img"); + img.src = currentPreviewBlobUrl; + img.alt = ""; + previewContent.appendChild(img); + } else { + const obj = document.createElement("object"); + obj.data = currentPreviewBlobUrl; + obj.type = "application/pdf"; + previewContent.appendChild(obj); + } + } + + if (previewStatusEl) previewStatusEl.textContent = ""; + } catch (err) { + if (currentPreviewPath !== path) return; + if (previewContent) previewContent.innerHTML = `
${escapeHtml(err.message || "Failed to load preview")}
`; + if (previewStatusEl) previewStatusEl.textContent = ""; + } +} + const FILE_ICON_MAP = { // Images jpg: "fa-file-image text-purple-400", jpeg: "fa-file-image text-purple-400", @@ -785,7 +950,7 @@ function createFileRow(entry) { const nameDiv = row.querySelector(".col-span-6"); const mobileMetaDiv = document.createElement("div"); mobileMetaDiv.className = "file-meta"; - mobileMetaDiv.innerHTML = `${size}${modTime}`; + mobileMetaDiv.innerHTML = `${size}·${modTime}`; nameDiv.appendChild(mobileMetaDiv); row.onclick = (e) => { @@ -795,6 +960,10 @@ function createFileRow(entry) { if (entry.isDir) { listFiles(entry.path); + } else if (isPreviewable(entry.name)) { + openFilePreview(entry.path, entry.size); + } else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) { + notifyToast("Binary file — use Download to save it", "info", 3000); } else { openFileInEditor(entry.path); } @@ -2284,7 +2453,12 @@ function handleFileSearchResult(msg) { `; row.onclick = () => { - openFileInEditor(result.path); + const resultName = result.path.split(/[\/\\]/).pop() || ""; + if (isPreviewable(resultName)) { + openFilePreview(result.path); + } else { + openFileInEditor(result.path); + } }; fileListEl.appendChild(row); @@ -2337,7 +2511,7 @@ function handleFileReadResult(msg) { } if (msg.isBinary) { - alert("Cannot edit binary file"); + notifyToast("Binary file — use Download to save it", "info", 3000); closeEditor(); return; } @@ -2459,6 +2633,22 @@ editorSaveBtn.addEventListener("click", saveFileFromEditor); editorCancelBtn.addEventListener("click", closeEditor); editorCloseBtn.addEventListener("click", closeEditor); +if (previewCloseBtn) previewCloseBtn.addEventListener("click", closePreview); +if (previewDownloadBtn) previewDownloadBtn.addEventListener("click", () => { + if (currentPreviewPath) downloadFile(currentPreviewPath); +}); +if (filePreviewModal) { + // Close on backdrop click. + filePreviewModal.addEventListener("click", (e) => { + if (e.target === filePreviewModal) closePreview(); + }); +} +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && filePreviewModal && filePreviewModal.classList.contains("show")) { + closePreview(); + } +}); + const editorRunBtn = document.getElementById("editor-run-btn"); editorRunBtn.addEventListener("click", () => { if (!currentEditingFile) return; diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html index a0d4eed..9de4dd5 100644 --- a/Overlord-Server/public/filebrowser.html +++ b/Overlord-Server/public/filebrowser.html @@ -123,6 +123,9 @@ .breadcrumb-item:hover { background-color: rgba(51, 65, 85, 0.5); } + .file-meta { + display: none; + } /* Mobile responsive styles */ @media (max-width: 768px) { @@ -276,6 +279,56 @@ border: 1px solid rgba(59, 130, 246, 0.4); } + /* File preview modal styles */ + .file-preview-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 2100; + } + .file-preview-modal.show { + display: flex; + } + .file-preview-content { + background-color: #1e293b; + border: 1px solid #334155; + border-radius: 0.75rem; + width: 92%; + max-width: 1100px; + height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5); + overflow: hidden; + } + #preview-content { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: #0f172a; + overflow: auto; + padding: 1rem; + } + #preview-content img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 0.25rem; + } + #preview-content object { + width: 100%; + height: 100%; + border: none; + } + /* Sidebar styles */ .sidebar-item { position: relative; @@ -689,6 +742,38 @@ + +
+
+
+
+ + Preview +
+
+ + +
+
+
+ +
+
+
+
+