add image previewing and pdf
This commit is contained in:
@@ -10,6 +10,8 @@ let fileUploads = new Map();
|
|||||||
let fileUploadsById = new Map();
|
let fileUploadsById = new Map();
|
||||||
let activeTransfers = new Map();
|
let activeTransfers = new Map();
|
||||||
let currentEditingFile = null;
|
let currentEditingFile = null;
|
||||||
|
let currentPreviewBlobUrl = null;
|
||||||
|
let currentPreviewPath = null;
|
||||||
let lastSuccessfulResponse = 0;
|
let lastSuccessfulResponse = 0;
|
||||||
let pendingToast = null;
|
let pendingToast = null;
|
||||||
const recentToasts = new Map();
|
const recentToasts = new Map();
|
||||||
@@ -69,6 +71,12 @@ const editorStatus = document.getElementById("editor-status");
|
|||||||
const editorSaveBtn = document.getElementById("editor-save-btn");
|
const editorSaveBtn = document.getElementById("editor-save-btn");
|
||||||
const editorCancelBtn = document.getElementById("editor-cancel-btn");
|
const editorCancelBtn = document.getElementById("editor-cancel-btn");
|
||||||
const editorCloseBtn = document.getElementById("editor-close-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) {
|
if (clientIdHeader) {
|
||||||
clientIdHeader.textContent = `${clientId} - File Browser`;
|
clientIdHeader.textContent = `${clientId} - File Browser`;
|
||||||
@@ -346,6 +354,163 @@ function getFileExt(name = "") {
|
|||||||
return name.slice(idx + 1).toLowerCase();
|
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 = {
|
const FILE_ICON_MAP = {
|
||||||
// Images
|
// Images
|
||||||
jpg: "fa-file-image text-purple-400", jpeg: "fa-file-image text-purple-400",
|
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 nameDiv = row.querySelector(".col-span-6");
|
||||||
const mobileMetaDiv = document.createElement("div");
|
const mobileMetaDiv = document.createElement("div");
|
||||||
mobileMetaDiv.className = "file-meta";
|
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);
|
nameDiv.appendChild(mobileMetaDiv);
|
||||||
|
|
||||||
row.onclick = (e) => {
|
row.onclick = (e) => {
|
||||||
@@ -795,6 +960,10 @@ function createFileRow(entry) {
|
|||||||
|
|
||||||
if (entry.isDir) {
|
if (entry.isDir) {
|
||||||
listFiles(entry.path);
|
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 {
|
} else {
|
||||||
openFileInEditor(entry.path);
|
openFileInEditor(entry.path);
|
||||||
}
|
}
|
||||||
@@ -2284,7 +2453,12 @@ function handleFileSearchResult(msg) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
row.onclick = () => {
|
row.onclick = () => {
|
||||||
openFileInEditor(result.path);
|
const resultName = result.path.split(/[\/\\]/).pop() || "";
|
||||||
|
if (isPreviewable(resultName)) {
|
||||||
|
openFilePreview(result.path);
|
||||||
|
} else {
|
||||||
|
openFileInEditor(result.path);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fileListEl.appendChild(row);
|
fileListEl.appendChild(row);
|
||||||
@@ -2337,7 +2511,7 @@ function handleFileReadResult(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.isBinary) {
|
if (msg.isBinary) {
|
||||||
alert("Cannot edit binary file");
|
notifyToast("Binary file — use Download to save it", "info", 3000);
|
||||||
closeEditor();
|
closeEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2459,6 +2633,22 @@ editorSaveBtn.addEventListener("click", saveFileFromEditor);
|
|||||||
editorCancelBtn.addEventListener("click", closeEditor);
|
editorCancelBtn.addEventListener("click", closeEditor);
|
||||||
editorCloseBtn.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");
|
const editorRunBtn = document.getElementById("editor-run-btn");
|
||||||
editorRunBtn.addEventListener("click", () => {
|
editorRunBtn.addEventListener("click", () => {
|
||||||
if (!currentEditingFile) return;
|
if (!currentEditingFile) return;
|
||||||
|
|||||||
@@ -123,6 +123,9 @@
|
|||||||
.breadcrumb-item:hover {
|
.breadcrumb-item:hover {
|
||||||
background-color: rgba(51, 65, 85, 0.5);
|
background-color: rgba(51, 65, 85, 0.5);
|
||||||
}
|
}
|
||||||
|
.file-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive styles */
|
/* Mobile responsive styles */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -276,6 +279,56 @@
|
|||||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
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 styles */
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -689,6 +742,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Transfer Progress Panel -->
|
||||||
<div
|
<div
|
||||||
id="transfer-panel"
|
id="transfer-panel"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SECURITY_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-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"X-XSS-Protection": "1; mode=block",
|
"X-XSS-Protection": "1; mode=block",
|
||||||
|
|||||||
Reference in New Issue
Block a user