Spaces:
Paused
Paused
| <html lang="id"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>File Manager</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| body.theme-dark { | |
| --bg:#0f172a; --panel:#111827; --panel-alt:#0b1221; | |
| --muted:#94a3b8; --txt:#e5e7eb; --link:#60a5fa; | |
| --pri:#22c55e; --danger:#ef4444; --warn:#f59e0b; | |
| --border:#1f2937; --border2:#334155; --btn-bg:#1f2937; | |
| --shadow: 0 4px 18px rgba(0,0,0,.25); | |
| } | |
| body.theme-light { | |
| --bg:#f8fafc; --panel:#ffffff; --panel-alt:#f1f5f9; | |
| --muted:#475569; --txt:#0f172a; --link:#2563eb; | |
| --pri:#16a34a; --danger:#dc2626; --warn:#d97706; | |
| --border:#e2e8f0; --border2:#cbd5e1; --btn-bg:#eef2f7; | |
| --shadow: 0 8px 24px rgba(15,23,42,.08); | |
| } | |
| body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Helvetica Neue',arial; | |
| background:var(--bg); color:var(--txt); transition: background .15s, color .15s; } | |
| header { padding:16px 20px; display:flex; align-items:center; gap:12px; border-bottom:1px solid var(--border); } | |
| header h1 { margin:0; font-size:18px; font-weight:600; } | |
| .bread a { color: var(--link); text-decoration:none; } | |
| .bread span.sep { margin: 0 6px; color: var(--muted); } | |
| main { padding:18px 20px; max-width:1100px; margin:auto; } | |
| .bar { display:flex; gap:8px; flex-wrap: wrap; margin-bottom:12px; align-items:center; } | |
| .bar input[type="text"] { background:var(--panel-alt); color:var(--txt); | |
| border:1px solid var(--border); border-radius:8px; padding:8px 10px; min-width: 220px; } | |
| .btn { background:var(--btn-bg); color:var(--txt); border:1px solid var(--border2); | |
| padding:8px 12px; border-radius:8px; cursor:pointer; } | |
| .btn:hover { filter:brightness(1.06); } | |
| .btn.pri { background:#064e3b; border-color:#065f46; } | |
| .btn.danger { background:#7f1d1d; border-color:#991b1b; } | |
| .btn.warn { background:#7c2d12; border-color:#9a3412; } | |
| .ghost { border:1px dashed var(--border); padding:10px; border-radius:8px; color:var(--muted); } | |
| table { width:100%; border-collapse: collapse; background:var(--panel); border-radius:12px; overflow:hidden; } | |
| th, td { padding:10px 12px; border-bottom:1px solid var(--border); } | |
| th { text-align:left; color:var(--muted); font-weight:500; background:var(--panel-alt); } | |
| tbody tr:hover { background:var(--panel-alt); } | |
| .name { display:flex; align-items:center; gap:10px; } | |
| .name a { color: var(--txt); text-decoration:none; } | |
| .muted { color:var(--muted); } | |
| .right { text-align:right; } | |
| .actions { display:flex; gap:6px; } | |
| .chip { font-size:12px; color:#0f172a; background:#a7f3d0; padding:2px 8px; border-radius:999px; } | |
| footer { color: var(--muted); text-align:center; padding:12px; font-size:12px; } | |
| .spacer { flex:1; } | |
| .hidden { display:none; } | |
| .note { font-size:12px; color:var(--muted); } | |
| .checkbox { width:18px; height:18px; vertical-align: middle; } | |
| .icon { width:18px; text-align:center; } | |
| .toast { position:fixed; right:16px; bottom:16px; background:var(--panel); border:1px solid var(--border2); | |
| padding:10px 12px; border-radius:8px; color:var(--txt); box-shadow:var(--shadow); } | |
| label.inline { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); } | |
| </style> | |
| </head> | |
| <body class="theme-dark"> | |
| <header> | |
| <h1>📁 File Manager</h1> | |
| <div class="bread" id="breadcrumb"></div> | |
| <span class="spacer"></span> | |
| <button class="btn" id="themeToggle" title="Toggle theme">🌙 Dark</button> | |
| <span class="note">TMPDIR</span> | |
| </header> | |
| <main> | |
| <div class="bar"> | |
| <input type="text" id="urlInput" placeholder="https://contoh.com/file.tgz" /> | |
| <input type="text" id="filenameInput" placeholder="Nama file (opsional)" /> | |
| <button class="btn pri" id="fetchBtn">Save dari URL</button> | |
| <label class="inline"><input type="checkbox" id="autoExtract" class="checkbox" checked /> Auto extract</label> | |
| <label class="inline"><input type="checkbox" id="removeArchive" class="checkbox" /> Hapus arsip</label> | |
| <input type="file" id="fileInput" multiple /> | |
| <button class="btn" id="uploadBtn">Upload</button> | |
| <button class="btn" id="newFolderBtn">Folder Baru</button> | |
| <select class="btn" id="archiveFormat" title="Pilih format arsip"> | |
| <option value="zip">ZIP</option> | |
| <option value="tar">TAR</option> | |
| <option value="tgz">TAR.GZ</option> | |
| </select> | |
| <button class="btn warn" id="zipBtn">Archive yang dipilih</button> | |
| <button class="btn danger" id="deleteSelectedBtn">Hapus yang dipilih</button> | |
| <span class="spacer"></span> | |
| <span class="note">Klik nama file untuk download</span> | |
| </div> | |
| <div id="emptyState" class="ghost hidden">Folder ini kosong.</div> | |
| <table id="table"> | |
| <thead> | |
| <tr> | |
| <th style="width:36px;"><input type="checkbox" id="selectAll" class="checkbox" /></th> | |
| <th>Nama</th> | |
| <th style="width:140px;">Ukuran</th> | |
| <th style="width:200px;">Diubah</th> | |
| <th style="width:220px;">Aksi</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tbody"></tbody> | |
| </table> | |
| <footer> | |
| Storage root: os.tmpdir() • Mode private (tanpa autentikasi) | |
| </footer> | |
| </main> | |
| <div id="toast" class="toast hidden"></div> | |
| <script> | |
| const tbody = document.getElementById("tbody"); | |
| const breadcrumb = document.getElementById("breadcrumb"); | |
| const emptyState = document.getElementById("emptyState"); | |
| const selectAll = document.getElementById("selectAll"); | |
| const urlInput = document.getElementById("urlInput"); | |
| const filenameInput = document.getElementById("filenameInput"); | |
| const fetchBtn = document.getElementById("fetchBtn"); | |
| const autoExtractEl = document.getElementById("autoExtract"); | |
| const removeArchiveEl = document.getElementById("removeArchive"); | |
| const fileInput = document.getElementById("fileInput"); | |
| const uploadBtn = document.getElementById("uploadBtn"); | |
| const newFolderBtn = document.getElementById("newFolderBtn"); | |
| const zipBtn = document.getElementById("zipBtn"); | |
| const deleteSelectedBtn = document.getElementById("deleteSelectedBtn"); | |
| const archiveFormat = document.getElementById("archiveFormat"); | |
| const themeToggle = document.getElementById("themeToggle"); | |
| const toast = document.getElementById("toast"); | |
| let currentPath = new URLSearchParams(location.search).get("path") || ""; | |
| // Theme | |
| function applyTheme(t) { | |
| document.body.classList.remove("theme-dark","theme-light"); | |
| if (t === "light") { | |
| document.body.classList.add("theme-light"); | |
| themeToggle.textContent = "☀️ Light"; | |
| } else { | |
| document.body.classList.add("theme-dark"); | |
| themeToggle.textContent = "🌙 Dark"; | |
| } | |
| } | |
| function initTheme() { | |
| const saved = localStorage.getItem("theme"); | |
| if (saved) applyTheme(saved); | |
| else { | |
| const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; | |
| applyTheme(prefersLight ? "light" : "dark"); | |
| } | |
| } | |
| themeToggle.onclick = () => { | |
| const nowDark = document.body.classList.contains("theme-dark"); | |
| const next = nowDark ? "light" : "dark"; | |
| applyTheme(next); | |
| localStorage.setItem("theme", next); | |
| }; | |
| initTheme(); | |
| function showToast(msg, ms = 2200) { | |
| toast.textContent = msg; | |
| toast.classList.remove("hidden"); | |
| setTimeout(() => toast.classList.add("hidden"), ms); | |
| } | |
| function fmtSize(bytes) { | |
| if (bytes == null) return "-"; | |
| const units = ["B","KB","MB","GB","TB"]; | |
| let i=0, n=bytes; | |
| while (n >= 1024 && i < units.length-1) { n/=1024; i++; } | |
| return (Math.round(n*10)/10) + " " + units[i]; | |
| } | |
| function fmtDate(ms) { | |
| const d = new Date(ms); | |
| return d.toLocaleString(); | |
| } | |
| function setPath(rel) { | |
| currentPath = rel || ""; | |
| const url = new URL(location.href); | |
| if (currentPath) url.searchParams.set("path", currentPath); | |
| else url.searchParams.delete("path"); | |
| history.replaceState(null, "", url); | |
| load(); | |
| } | |
| function stripArchiveExt(name) { | |
| const lower = String(name).toLowerCase(); | |
| if (lower.endsWith(".tar.gz")) return name.slice(0, -7); | |
| if (lower.endsWith(".tgz")) return name.slice(0, -4); | |
| if (lower.endsWith(".zip")) return name.slice(0, -4); | |
| if (lower.endsWith(".tar")) return name.slice(0, -4); | |
| return name; | |
| } | |
| async function load() { | |
| const res = await fetch(`/api/list?path=${encodeURIComponent(currentPath)}`); | |
| const data = await res.json(); | |
| render(data); | |
| } | |
| function render(data) { | |
| // Breadcrumb | |
| breadcrumb.innerHTML = ""; | |
| data.breadcrumb.forEach((c, idx) => { | |
| const a = document.createElement("a"); | |
| a.href = `?path=${encodeURIComponent(c.path)}`; | |
| a.textContent = c.name; | |
| a.onclick = (e) => { e.preventDefault(); setPath(c.path); }; | |
| breadcrumb.appendChild(a); | |
| if (idx < data.breadcrumb.length - 1) { | |
| const sep = document.createElement("span"); | |
| sep.textContent = "›"; | |
| sep.className = "sep"; | |
| breadcrumb.appendChild(sep); | |
| } | |
| }); | |
| // Rows | |
| tbody.innerHTML = ""; | |
| if (!data.items.length) emptyState.classList.remove("hidden"); else emptyState.classList.add("hidden"); | |
| data.items.forEach(item => { | |
| const tr = document.createElement("tr"); | |
| const tdSel = document.createElement("td"); | |
| const cb = document.createElement("input"); | |
| cb.type = "checkbox"; cb.className = "checkbox"; | |
| cb.dataset.name = item.name; | |
| tdSel.appendChild(cb); | |
| const tdName = document.createElement("td"); | |
| tdName.className = "name"; | |
| const icon = document.createElement("span"); | |
| icon.className = "icon"; | |
| icon.textContent = item.isDir ? "📂" : "📄"; | |
| const link = document.createElement("a"); | |
| link.href = "#"; | |
| link.textContent = item.name; | |
| if (item.isDir) { | |
| link.onclick = (e) => { e.preventDefault(); setPath(item.relPath); }; | |
| } else { | |
| link.onclick = (e) => { | |
| e.preventDefault(); | |
| window.location = `/api/download?path=${encodeURIComponent(item.relPath)}`; | |
| }; | |
| } | |
| tdName.append(icon, link); | |
| if (!item.isDir && item.archFormat) { | |
| const chip = document.createElement("span"); | |
| chip.className = "chip"; | |
| chip.textContent = item.archFormat === "zip" ? "ZIP" : (item.archFormat === "tar" ? "TAR" : "TGZ"); | |
| tdName.appendChild(chip); | |
| } | |
| const tdSize = document.createElement("td"); | |
| tdSize.className = "right muted"; | |
| tdSize.textContent = fmtSize(item.size); | |
| const tdTime = document.createElement("td"); | |
| tdTime.className = "muted"; | |
| tdTime.textContent = fmtDate(item.mtime); | |
| const tdAct = document.createElement("td"); | |
| const actions = document.createElement("div"); | |
| actions.className = "actions"; | |
| const renameBtn = document.createElement("button"); | |
| renameBtn.className = "btn"; | |
| renameBtn.textContent = "Rename"; | |
| renameBtn.onclick = async () => { | |
| const newName = prompt("Nama baru:", item.name); | |
| if (!newName || newName === item.name) return; | |
| const res = await fetch("/api/rename", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ path: item.relPath, newName }) | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal rename"); | |
| showToast("Berhasil di-rename"); | |
| load(); | |
| }; | |
| const delBtn = document.createElement("button"); | |
| delBtn.className = "btn danger"; | |
| delBtn.textContent = "Hapus"; | |
| delBtn.onclick = async () => { | |
| if (!confirm(`Hapus ${item.name}?`)) return; | |
| const res = await fetch(`/api/entry?path=${encodeURIComponent(item.relPath)}`, { method: "DELETE" }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal hapus"); | |
| showToast("Berhasil dihapus"); | |
| load(); | |
| }; | |
| if (!item.isDir && item.archFormat) { | |
| const unzipBtn = document.createElement("button"); | |
| unzipBtn.className = "btn warn"; | |
| unzipBtn.textContent = "Unarchive"; | |
| unzipBtn.onclick = async () => { | |
| const defaultName = stripArchiveExt(item.name); | |
| const destDefault = `${currentPath ? currentPath + "/" : ""}${defaultName}`; | |
| const dest = prompt("Extract ke folder (opsional):", destDefault); | |
| const body = dest ? { zipPath: item.relPath, destDir: dest } : { zipPath: item.relPath }; | |
| const res = await fetch("/api/unarchive", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body) | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal unarchive"); | |
| showToast("Berhasil di-extract"); | |
| load(); | |
| }; | |
| actions.appendChild(unzipBtn); | |
| } | |
| actions.append(renameBtn, delBtn); | |
| tdAct.appendChild(actions); | |
| tr.append(tdSel, tdName, tdSize, tdTime, tdAct); | |
| tbody.appendChild(tr); | |
| }); | |
| selectAll.checked = false; | |
| } | |
| async function archiveSelected() { | |
| const selected = [...document.querySelectorAll('#tbody input[type="checkbox"]:checked')].map(cb => cb.dataset.name); | |
| if (!selected.length) return showToast("Tidak ada yang dipilih"); | |
| let name = prompt("Nama file arsip (opsional, tanpa ekstensi):", ""); | |
| if (name === null) return; | |
| const format = archiveFormat.value; // zip | tar | tgz | |
| const res = await fetch("/api/archive", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ path: currentPath, entries: selected, name, format }) | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal membuat arsip"); | |
| showToast("Arsip dibuat"); | |
| load(); | |
| } | |
| async function deleteSelected() { | |
| const selected = [...document.querySelectorAll('#tbody input[type="checkbox"]:checked')].map(cb => cb.dataset.name); | |
| if (!selected.length) return showToast("Tidak ada yang dipilih"); | |
| if (!confirm(`Hapus ${selected.length} item?`)) return; | |
| for (const name of selected) { | |
| const rel = currentPath ? currentPath + "/" + name : name; | |
| const res = await fetch(`/api/entry?path=${encodeURIComponent(rel)}`, { method: "DELETE" }); | |
| if (!res.ok) { | |
| const j = await res.json().catch(() => ({})); | |
| showToast(j.error || `Gagal hapus ${name}`); | |
| return; | |
| } | |
| } | |
| showToast("Berhasil dihapus"); | |
| load(); | |
| } | |
| fetchBtn.onclick = async () => { | |
| const url = urlInput.value.trim(); | |
| const filename = filenameInput.value.trim(); | |
| if (!url) return showToast("URL kosong"); | |
| const body = { | |
| url, | |
| destDir: currentPath, | |
| autoExtract: autoExtractEl.checked, | |
| removeArchive: removeArchiveEl.checked | |
| }; | |
| if (filename) body.filename = filename; | |
| const res = await fetch("/api/fetch-url", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body) | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal download dari URL"); | |
| if (j.extractedTo) showToast(`Diunduh & diextract ke: ${j.extractedTo}`); | |
| else showToast("Berhasil diunduh ke folder ini"); | |
| urlInput.value = ""; filenameInput.value = ""; | |
| load(); | |
| }; | |
| uploadBtn.onclick = async () => { | |
| const files = fileInput.files; | |
| if (!files || !files.length) return showToast("Pilih file terlebih dahulu"); | |
| const fd = new FormData(); | |
| for (const f of files) fd.append("files", f); | |
| const res = await fetch(`/api/upload?path=${encodeURIComponent(currentPath)}`, { | |
| method: "POST", | |
| body: fd | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal upload"); | |
| showToast("Upload selesai"); | |
| fileInput.value = ""; | |
| load(); | |
| }; | |
| newFolderBtn.onclick = async () => { | |
| const name = prompt("Nama folder baru:"); | |
| if (!name) return; | |
| const res = await fetch("/api/folder", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ parent: currentPath, name }) | |
| }); | |
| const j = await res.json(); | |
| if (!res.ok) return showToast(j.error || "Gagal membuat folder"); | |
| showToast("Folder dibuat"); | |
| load(); | |
| }; | |
| zipBtn.onclick = archiveSelected; | |
| deleteSelectedBtn.onclick = deleteSelected; | |
| selectAll.onchange = () => { | |
| document.querySelectorAll('#tbody input[type="checkbox"]').forEach(cb => cb.checked = selectAll.checked); | |
| }; | |
| load(); | |
| </script> | |
| </body> | |
| </html> |