Spaces:
Running
Running
| ; | |
| (function () { | |
| const $ = (sel) => document.querySelector(sel); | |
| const $$ = (sel) => Array.from(document.querySelectorAll(sel)); | |
| const dropZone = $("#dropZone"); | |
| const fileInput = $("#fileInput"); | |
| const searchInput = $("#searchInput"); | |
| const clearBtn = $("#clearBtn"); | |
| const downloadAllBtn = $("#downloadAllBtn"); | |
| const statusEl = $("#status"); | |
| const progressEl = $("#progress"); | |
| const progressBar = $("#progressBar"); | |
| const fileNameEl = $("#fileName"); | |
| const fileSizeEl = $("#fileSize"); | |
| const fileListEl = $("#fileList"); | |
| const countEl = $("#count"); | |
| const previewSection = $("#previewSection"); | |
| const previewContent = $("#previewContent"); | |
| const closePreviewBtn = $("#closePreviewBtn"); | |
| const state = { | |
| zip: null, | |
| entries: [], // { name, file, size, type, lastModDate, url? } | |
| objectUrls: [], | |
| totalBytes: 0, | |
| loadedBytes: 0, | |
| currentZipFile: null | |
| }; | |
| // Utils | |
| function bytesToSize(bytes) { | |
| if (bytes === 0) return "0 B"; | |
| const k = 1024; | |
| const sizes = ["B", "KB", "MB", "GB", "TB"]; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| const val = parseFloat((bytes / Math.pow(k, i)).toFixed(2)); | |
| return `${val} ${sizes[i]}`; | |
| } | |
| function sanitizeName(name) { | |
| // Avoid path traversal and invalid characters | |
| const cleaned = name.replace(/^(\.*\/)+/, "").replace(/\.\./g, ""); | |
| return cleaned || "file"; | |
| } | |
| function setStatus(text, type = "info") { | |
| statusEl.textContent = text; | |
| statusEl.className = "status"; | |
| if (type === "error") { | |
| statusEl.style.color = "#ef4444"; | |
| } else if (type === "success") { | |
| statusEl.style.color = "#22c55e"; | |
| } else { | |
| statusEl.style.color = "var(--muted)"; | |
| } | |
| } | |
| function setProgress(percent) { | |
| if (percent <= 0 || percent >= 100) { | |
| progressEl.style.display = "none"; | |
| progressEl.setAttribute("aria-hidden", "true"); | |
| } else { | |
| progressEl.style.display = "block"; | |
| progressEl.setAttribute("aria-hidden", "false"); | |
| } | |
| progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`; | |
| } | |
| function clearObjectUrls() { | |
| state.objectUrls.forEach((url) => URL.revokeObjectURL(url)); | |
| state.objectUrls = []; | |
| } | |
| function resetAll() { | |
| state.zip = null; | |
| state.entries = []; | |
| state.totalBytes = 0; | |
| state.loadedBytes = 0; | |
| state.currentZipFile = null; | |
| clearObjectUrls(); | |
| fileListEl.innerHTML = ""; | |
| countEl.textContent = "0"; | |
| fileNameEl.textContent = ""; | |
| fileSizeEl.textContent = ""; | |
| setStatus("No file loaded."); | |
| setProgress(0); | |
| clearBtn.disabled = true; | |
| downloadAllBtn.disabled = true; | |
| hidePreview(); | |
| } | |
| function hidePreview() { | |
| previewSection.hidden = true; | |
| previewContent.innerHTML = ""; | |
| } | |
| function showPreviewFor(entry) { | |
| previewSection.hidden = false; | |
| previewContent.innerHTML = ""; | |
| const type = (entry.type || "").toLowerCase(); | |
| const name = entry.name; | |
| // Text preview | |
| if (type.startsWith("text/") || /\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|scss|sass|less|yml|yaml|xml|log)$/i.test(name)) { | |
| const pre = document.createElement("pre"); | |
| pre.textContent = "Loading preview..."; | |
| entry.file.text().then((txt) => { | |
| pre.textContent = txt; | |
| }).catch(() => { | |
| pre.textContent = "Unable to preview this text file."; | |
| }); | |
| previewContent.appendChild(pre); | |
| return; | |
| } | |
| // Image preview | |
| if (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(name)) { | |
| const img = document.createElement("img"); | |
| img.alt = name; | |
| const url = URL.createObjectURL(entry.file); | |
| state.objectUrls.push(url); | |
| img.src = url; | |
| img.onload = () => { | |
| // Keep object URL for preview lifetime; will be revoked on clear/reset | |
| }; | |
| previewContent.appendChild(img); | |
| return; | |
| } | |
| // PDF preview | |
| if (type === "application/pdf" || /\.pdf$/i.test(name)) { | |
| const embed = document.createElement("embed"); | |
| embed.type = "application/pdf"; | |
| const url = URL.createObjectURL(entry.file); | |
| state.objectUrls.push(url); | |
| embed.src = url; | |
| previewContent.appendChild(embed); | |
| return; | |
| } | |
| // Fallback message | |
| const p = document.createElement("p"); | |
| p.textContent = "Preview is not available for this file type."; | |
| previewContent.appendChild(p); | |
| } | |
| function renderList(entries) { | |
| fileListEl.innerHTML = ""; | |
| const frag = document.createDocumentFragment(); | |
| entries.forEach((entry) => { | |
| const li = document.createElement("li"); | |
| li.className = "file"; | |
| const nameCol = document.createElement("div"); | |
| nameCol.className = "file__name"; | |
| const badge = document.createElement("span"); | |
| badge.className = "badge"; | |
| badge.textContent = entry.type || "file"; | |
| const path = document.createElement("span"); | |
| path.className = "path"; | |
| path.textContent = entry.name; | |
| nameCol.appendChild(badge); | |
| nameCol.appendChild(path); | |
| const size = document.createElement("div"); | |
| size.className = "file__size"; | |
| size.textContent = bytesToSize(entry.size); | |
| const actions = document.createElement("div"); | |
| actions.className = "file__actions"; | |
| const previewBtn = document.createElement("button"); | |
| previewBtn.className = "btn btn-secondary"; | |
| previewBtn.textContent = "Preview"; | |
| previewBtn.addEventListener("click", () => showPreviewFor(entry)); | |
| const downloadBtn = document.createElement("button"); | |
| downloadBtn.className = "btn btn-primary"; | |
| downloadBtn.textContent = "Download"; | |
| downloadBtn.addEventListener("click", () => { | |
| const a = document.createElement("a"); | |
| const url = URL.createObjectURL(entry.file); | |
| state.objectUrls.push(url); | |
| a.href = url; | |
| a.download = entry.name.split("/").pop(); | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| }); | |
| actions.appendChild(previewBtn); | |
| actions.appendChild(downloadBtn); | |
| li.appendChild(nameCol); | |
| li.appendChild(size); | |
| li.appendChild(actions); | |
| frag.appendChild(li); | |
| }); | |
| fileListEl.appendChild(frag); | |
| countEl.textContent = String(entries.length); | |
| } | |
| async function handleZipFile(file) { | |
| resetAll(); | |
| state.currentZipFile = file; | |
| fileNameEl.textContent = file.name || "ZIP archive"; | |
| fileSizeEl.textContent = bytesToSize(file.size); | |
| setStatus("Loading ZIP..."); | |
| setProgress(2); | |
| try { | |
| const zip = await JSZip.loadAsync(file); | |
| state.zip = zip; | |
| const entries = []; | |
| let processed = 0; | |
| const total = Object.keys(zip.files).length; | |
| for (const [path, entry] of Object.entries(zip.files)) { | |
| // Skip directories | |
| if (entry.dir) { | |
| processed++; | |
| setProgress(Math.round((processed / total) * 100)); | |
| continue; | |
| } | |
| const safeName = sanitizeName(path); | |
| // Obtain as Blob | |
| const blob = await entry.async("blob"); | |
| const fileObj = new File([blob], safeName, { type: blob.type || "application/octet-stream", lastModified: entry.date?.getTime?.() || Date.now() }); | |
| entries.push({ | |
| name: safeName, | |
| file: fileObj, | |
| size: fileObj.size, | |
| type: fileObj.type || "application/octet-stream", | |
| lastModDate: entry.date | |
| }); | |
| processed++; | |
| if (processed % 5 === 0) { | |
| setProgress(Math.round((processed / total) * 100)); | |
| await new Promise((r) => setTimeout(r, 0)); // Yield to UI | |
| } | |
| } | |
| // Sort by path then size desc for consistent order | |
| entries.sort((a, b) => a.name.localeCompare(b.name) || b.size - a.size); | |
| state.entries = entries; | |
| state.totalBytes = entries.reduce((acc, e) => acc + e.size, 0); | |
| state.loadedBytes = state.totalBytes; | |
| renderList(entries); | |
| setStatus(`Loaded ${entries.length} files from "${file.name}".`); | |
| setProgress(100); | |
| clearBtn.disabled = false; | |
| downloadAllBtn.disabled = entries.length === 0; | |
| } catch (err) { | |
| console.error(err); | |
| setStatus("Failed to read the ZIP file. It may be corrupted or unsupported.", "error"); | |
| setProgress(0); | |
| } | |
| } | |
| function filterEntries(query) { | |
| const q = query.trim().toLowerCase(); | |
| if (!q) return state.entries; | |
| return state.entries.filter((e) => e.name.toLowerCase().includes(q)); | |
| } | |
| async function downloadAll() { | |
| if (!state.entries.length) return; | |
| const zip = new JSZip(); | |
| for (const e of state.entries) { | |
| zip.file(e.name, e.file); | |
| } | |
| setStatus("Preparing ZIP for download..."); | |
| setProgress(3); | |
| try { | |
| const blob = await zip.generateAsync( | |
| { type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }, | |
| (metadata) => { | |
| setProgress(Math.max(3, Math.min(99, Math.round(metadata.percent)))); | |
| } | |
| ); | |
| const a = document.createElement("a"); | |
| const url = URL.createObjectURL(blob); | |
| a.href = url; | |
| a.download = (state.currentZipFile?.name?.replace(/\.zip$/i, "") || "extracted") + "-extracted.zip"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| setStatus("ZIP ready for download.", "success"); | |
| setProgress(100); | |
| } catch (err) { | |
| console.error(err); | |
| setStatus("Failed to create ZIP for download.", "error"); | |
| setProgress(0); | |
| } | |
| } | |
| // Event handlers | |
| dropZone.addEventListener("click", () => fileInput.click()); | |
| dropZone.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| fileInput.click(); | |
| } | |
| }); | |
| ["dragenter", "dragover"].forEach((evt) => { | |
| dropZone.addEventListener(evt, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropZone.classList.add("is-dragover"); | |
| }); | |
| }); | |
| ["dragleave", "drop"].forEach((evt) => { | |
| dropZone.addEventListener(evt, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (evt === "drop") { | |
| const dt = e.dataTransfer; | |
| const file = dt?.files?.[0]; | |
| if (file) handleZipFile(file); | |
| } | |
| dropZone.classList.remove("is-dragover"); | |
| }); | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| const file = fileInput.files?.[0]; | |
| if (file) handleZipFile(file); | |
| }); | |
| searchInput.addEventListener("input", () => { | |
| renderList(filterEntries(searchInput.value)); | |
| }); | |
| clearBtn.addEventListener("click", () => { | |
| fileInput.value = ""; | |
| resetAll(); | |
| }); | |
| downloadAllBtn.addEventListener("click", () => { | |
| downloadAll(); | |
| }); | |
| closePreviewBtn.addEventListener("click", () => { | |
| hidePreview(); | |
| }); | |
| // Init | |
| resetAll(); | |
| })(); |