add image previewing and pdf

This commit is contained in:
KingKDot
2026-03-26 09:55:02 -05:00
parent e0c176f3fb
commit 8f496c97d9
3 changed files with 279 additions and 4 deletions
+193 -3
View File
@@ -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 = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>Failed to load preview</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(text || "Failed to load preview")}</div>`;
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 = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(contentLength))})</div>`;
if (previewStatusEl) previewStatusEl.textContent = "";
return;
}
const arrayBuffer = await res.arrayBuffer();
if (currentPreviewPath !== path) return;
if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})</div>`;
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 = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(err.message || "Failed to load preview")}</div>`;
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 = `<span>${size}</span><span>${modTime}</span>`;
mobileMetaDiv.innerHTML = `<span>${size}</span><span class="opacity-30 select-none mx-1">·</span><span>${modTime}</span>`;
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;
+85
View File
@@ -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 @@
</div>
</div>
<!-- File Preview Modal -->
<div id="file-preview-modal" class="file-preview-modal">
<div class="file-preview-content">
<div class="px-4 py-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-3 min-w-0">
<i class="fa-solid fa-eye text-blue-400 flex-shrink-0"></i>
<span class="font-semibold truncate" id="preview-file-name">Preview</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
<button
id="preview-download-btn"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
title="Download file"
>
<i class="fa-solid fa-download"></i> Download
</button>
<button
id="preview-close-btn"
class="px-2 py-1 rounded hover:text-red-400 text-slate-400 transition-colors"
title="Close preview"
>
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
<div id="preview-content">
<!-- Image or PDF will be rendered here -->
</div>
<div id="preview-status" class="px-4 py-2 text-sm text-slate-400 text-center flex-shrink-0"></div>
</div>
</div>
<!-- Transfer Progress Panel -->
<div
id="transfer-panel"
+1 -1
View File
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
};
export const SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net; object-src blob:",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",