555 lines
21 KiB
HTML
555 lines
21 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>Datei-Upload</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0b0f1a;
|
||
--bg-raise: #111827;
|
||
--bg-card: #151d2e;
|
||
--border: #1f2937;
|
||
--border-strong: #374151;
|
||
--text: #e5e7eb;
|
||
--text-muted: #9ca3af;
|
||
--text-dim: #6b7280;
|
||
--primary: #6366f1;
|
||
--primary-hover: #818cf8;
|
||
--primary-fg: #ffffff;
|
||
--success: #10b981;
|
||
--danger: #ef4444;
|
||
--radius: 14px;
|
||
--radius-sm: 8px;
|
||
--shadow: 0 8px 32px rgba(0,0,0,.35);
|
||
color-scheme: dark;
|
||
}
|
||
@media (prefers-color-scheme: light) {
|
||
:root {
|
||
--bg: #f8fafc; --bg-raise: #fff; --bg-card: #fff;
|
||
--border: #e2e8f0; --border-strong: #cbd5e1;
|
||
--text: #0f172a; --text-muted: #475569; --text-dim: #94a3b8;
|
||
--shadow: 0 4px 24px rgba(15,23,42,.08);
|
||
color-scheme: light;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||
background: radial-gradient(ellipse at top, color-mix(in srgb, var(--primary) 8%, var(--bg)), var(--bg));
|
||
color: var(--text); line-height: 1.5; min-height: 100vh;
|
||
display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem;
|
||
}
|
||
header { width: 100%; max-width: 720px; margin-bottom: 2rem; text-align: left; }
|
||
header img { max-height: 56px; display: block; }
|
||
.card {
|
||
width: 100%; max-width: 720px; background: var(--bg-card);
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 2rem; box-shadow: var(--shadow);
|
||
}
|
||
h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
|
||
.subtitle { color: var(--text-muted); margin: 0 0 1.5rem; font-size: .95rem; }
|
||
|
||
.drop {
|
||
border: 2px dashed var(--border-strong); border-radius: var(--radius);
|
||
padding: 3rem 1rem; text-align: center;
|
||
transition: background .15s, border-color .15s, transform .15s;
|
||
cursor: pointer;
|
||
}
|
||
.drop:hover { border-color: color-mix(in srgb, var(--primary) 70%, var(--border-strong)); }
|
||
.drop.drag {
|
||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||
border-color: var(--primary); transform: scale(1.01);
|
||
}
|
||
.drop .icon {
|
||
width: 56px; height: 56px; margin: 0 auto 1rem;
|
||
border-radius: 50%; background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||
display: grid; place-items: center; color: var(--primary);
|
||
}
|
||
.drop .icon svg { width: 28px; height: 28px; }
|
||
.drop .title { font-size: 1.05rem; font-weight: 500; margin-bottom: .25rem; }
|
||
.drop .hint { color: var(--text-muted); font-size: .9rem; margin-bottom: 1rem; }
|
||
.drop .buttons { display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }
|
||
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: .4rem;
|
||
padding: .55rem 1rem; border: 1px solid var(--border-strong);
|
||
background: transparent; color: var(--text);
|
||
border-radius: var(--radius-sm); font: inherit; cursor: pointer;
|
||
transition: background .15s, border-color .15s;
|
||
}
|
||
.btn:hover { background: var(--bg); border-color: var(--primary); }
|
||
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
||
.btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||
.btn input { display: none; }
|
||
|
||
input[type="password"] {
|
||
width: 100%; padding: .6rem .8rem; font: inherit;
|
||
background: var(--bg); color: var(--text);
|
||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||
}
|
||
input:focus { outline: none; border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); }
|
||
|
||
#list { margin-top: 1.5rem; display: grid; gap: .5rem; }
|
||
.file-row {
|
||
display: grid; grid-template-columns: auto 1fr auto;
|
||
gap: .75rem; align-items: center;
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
padding: .65rem .85rem; border-radius: var(--radius-sm);
|
||
font-size: .88rem;
|
||
}
|
||
.file-row .name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.file-row .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||
.file-row .status { width: 4.5em; text-align: right; font-variant-numeric: tabular-nums; font-size: .85rem; }
|
||
.file-row progress { grid-column: 1 / -1; width: 100%; height: 4px; border: 0;
|
||
border-radius: 2px; background: var(--border); overflow: hidden; }
|
||
.file-row progress::-webkit-progress-bar { background: var(--border); }
|
||
.file-row progress::-webkit-progress-value { background: var(--primary); transition: width .2s; }
|
||
.file-row progress::-moz-progress-bar { background: var(--primary); }
|
||
.file-row.done .status { color: var(--success); }
|
||
.file-row.err .status { color: var(--danger); }
|
||
.file-row.err progress::-webkit-progress-value { background: var(--danger); }
|
||
|
||
.gate { display: none; padding: 1rem; background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); }
|
||
.gate p { margin: 0 0 .75rem; color: var(--text-muted); font-size: .9rem; }
|
||
.gate .row { display: flex; gap: .5rem; }
|
||
.err-line { color: var(--danger); font-size: .85rem; margin-top: .5rem; }
|
||
|
||
.expires { display: inline-flex; align-items: center; gap: .35rem;
|
||
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
||
color: var(--text-muted); border: 1px solid var(--border-strong); }
|
||
|
||
.browser { margin-top: 2rem; }
|
||
.browser h2 { font-size: 1rem; margin: 0 0 .5rem; display: flex; align-items: center; gap: .5rem; }
|
||
.browser .count { color: var(--text-muted); font-size: .85rem; font-weight: normal; }
|
||
.browser .empty { color: var(--text-muted); font-size: .9rem; padding: 1rem; text-align: center;
|
||
border: 1px dashed var(--border); border-radius: var(--radius-sm); }
|
||
.browser .row {
|
||
display: grid; grid-template-columns: auto 1fr auto auto; gap: .75rem;
|
||
align-items: center; padding: .55rem .75rem; border: 1px solid var(--border);
|
||
background: var(--bg); border-radius: var(--radius-sm); margin-bottom: .35rem;
|
||
font-size: .88rem;
|
||
}
|
||
.browser .row .icon { font-size: 1rem; opacity: .7; }
|
||
.browser .row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.browser .row .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||
.browser .row .dl { padding: .3rem .65rem; font-size: .8rem; }
|
||
.browser .row.dir .name { cursor: pointer; color: var(--primary); }
|
||
.browser .row.dir .name:hover { text-decoration: underline; }
|
||
.browser .crumbs {
|
||
display: flex; flex-wrap: wrap; gap: .25rem; align-items: center;
|
||
margin-bottom: .5rem; font-size: .9rem;
|
||
}
|
||
.browser .crumbs button {
|
||
background: transparent; border: none; color: var(--primary);
|
||
font: inherit; cursor: pointer; padding: .15rem .35rem; border-radius: 4px;
|
||
}
|
||
.browser .crumbs button:hover { background: color-mix(in srgb, var(--primary) 12%, transparent); }
|
||
.browser .crumbs span { color: var(--text-dim); }
|
||
.browser .toolbar { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
|
||
|
||
footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header><img id="logo" alt="" style="display:none" /></header>
|
||
|
||
<div class="card">
|
||
<h1 id="title">Datei-Upload</h1>
|
||
<p class="subtitle" id="info"></p>
|
||
|
||
<div class="gate" id="gate">
|
||
<p>Dieser Link ist passwortgeschützt. Bitte gib das Passwort ein:</p>
|
||
<div class="row">
|
||
<input type="password" id="pw" placeholder="Passwort" autocomplete="off" />
|
||
<button class="btn primary" id="pwBtn">Entsperren</button>
|
||
</div>
|
||
<div class="err-line" id="pwErr" style="display:none">Passwort falsch.</div>
|
||
</div>
|
||
|
||
<div id="main" style="display:none">
|
||
<div class="drop" id="drop">
|
||
<div class="icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="17 8 12 3 7 8"/>
|
||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||
</svg>
|
||
</div>
|
||
<div class="title">Dateien oder Ordner hier hineinziehen</div>
|
||
<div class="hint">oder auswählen</div>
|
||
<div class="buttons">
|
||
<label class="btn"><input type="file" id="fileInput" multiple />📄 Dateien</label>
|
||
<label class="btn"><input type="file" id="dirInput" webkitdirectory multiple />📁 Ordner</label>
|
||
</div>
|
||
</div>
|
||
<div id="list"></div>
|
||
|
||
<div class="browser">
|
||
<h2>Bisher hochgeladene und/oder empfangene Dateien <span class="count" id="fileCount"></span></h2>
|
||
<div class="crumbs" id="crumbs"></div>
|
||
<div class="toolbar"><button type="button" class="btn dl" id="zipCurrent">⬇ Aktuellen Ordner als ZIP</button></div>
|
||
<div id="fileBrowser"><div class="empty">– wird geladen –</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer id="footer"></footer>
|
||
|
||
<script>
|
||
const token = location.pathname.split('/').filter(Boolean)[1];
|
||
let password = '';
|
||
|
||
const info = document.getElementById('info');
|
||
const gate = document.getElementById('gate');
|
||
const main = document.getElementById('main');
|
||
const drop = document.getElementById('drop');
|
||
const list = document.getElementById('list');
|
||
|
||
async function applyBranding() {
|
||
try {
|
||
const r = await fetch('/admin/api/branding');
|
||
const b = await r.json();
|
||
if (b.logo_filename) {
|
||
const logo = document.getElementById('logo');
|
||
logo.src = '/logo?t=' + Date.now();
|
||
if (b.logo_width_px > 0) logo.style.width = b.logo_width_px + 'px';
|
||
if (b.logo_height_px > 0) logo.style.height = b.logo_height_px + 'px';
|
||
if (!b.logo_width_px && !b.logo_height_px) logo.style.maxHeight = '56px';
|
||
logo.style.display = '';
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function applyInfo(data) {
|
||
window._uploadInfo = data;
|
||
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
||
if (data.expired) {
|
||
info.innerHTML = `<span class="expires" style="color:#f59e0b; border-color:#f59e0b">⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.</span>`;
|
||
} else if (data.expires_at) {
|
||
info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`;
|
||
} else {
|
||
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
||
}
|
||
// Browser visibility synced to expiry
|
||
const browser = document.querySelector('.browser');
|
||
if (browser) browser.style.display = data.expired ? 'none' : '';
|
||
}
|
||
|
||
function showLinkGone(reason) {
|
||
stopPolling();
|
||
document.querySelector('.card').innerHTML =
|
||
`<h1>Link nicht mehr verfügbar</h1><p class="subtitle">${reason}</p>`;
|
||
}
|
||
|
||
async function init() {
|
||
await applyBranding();
|
||
const r = await fetch(`/u/${token}/info`);
|
||
if (!r.ok) {
|
||
showLinkGone('Bitte wende dich an deinen Ansprechpartner für einen neuen Link.');
|
||
return;
|
||
}
|
||
const data = await r.json();
|
||
applyInfo(data);
|
||
if (data.has_password) gate.style.display = 'block';
|
||
else { main.style.display = 'block'; loadFiles(); }
|
||
startPolling();
|
||
}
|
||
|
||
// --- Auto-refresh ---
|
||
const POLL_INTERVAL_MS = 20000;
|
||
let pollTimer = null;
|
||
let pollBusy = false;
|
||
let lastInfoSig = '';
|
||
let lastFilesSig = '';
|
||
|
||
async function pollState() {
|
||
if (pollBusy) return;
|
||
pollBusy = true;
|
||
try {
|
||
const r = await fetch(`/u/${token}/info`);
|
||
if (r.status === 404) {
|
||
showLinkGone('Der Upload-Link wurde deaktiviert oder gelöscht.');
|
||
return;
|
||
}
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const sig = JSON.stringify(data);
|
||
if (sig !== lastInfoSig) {
|
||
lastInfoSig = sig;
|
||
applyInfo(data);
|
||
// Newly expired or unexpired → reload list
|
||
if (!data.expired && main.style.display !== 'none') loadFiles();
|
||
}
|
||
if (!data.expired && main.style.display !== 'none') await refreshFilesIfChanged();
|
||
} catch {}
|
||
finally { pollBusy = false; }
|
||
}
|
||
|
||
async function refreshFilesIfChanged() {
|
||
try {
|
||
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||
const r = await fetch(url, { headers: authHeaders() });
|
||
if (r.status === 410) { applyInfo({ ...(window._uploadInfo || {}), expired: true }); return; }
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const sig = JSON.stringify(data);
|
||
if (sig === lastFilesSig) return;
|
||
lastFilesSig = sig;
|
||
renderFiles(data);
|
||
} catch {}
|
||
}
|
||
|
||
function startPolling() {
|
||
if (pollTimer) return;
|
||
pollTimer = setInterval(pollState, POLL_INTERVAL_MS);
|
||
}
|
||
function stopPolling() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||
}
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) stopPolling();
|
||
else if (window._uploadInfo) startPolling();
|
||
});
|
||
|
||
function authHeaders() {
|
||
return password ? { 'X-Upload-Password': password } : {};
|
||
}
|
||
|
||
let currentDir = '';
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||
}
|
||
|
||
function renderCrumbs() {
|
||
const cr = document.getElementById('crumbs');
|
||
const segs = currentDir ? currentDir.split('/') : [];
|
||
let acc = '';
|
||
let html = `<button data-go="">🏠 Hauptordner</button>`;
|
||
for (const s of segs) {
|
||
acc = acc ? acc + '/' + s : s;
|
||
html += `<span>›</span><button data-go="${esc(acc)}">${esc(s)}</button>`;
|
||
}
|
||
cr.innerHTML = html;
|
||
}
|
||
|
||
function renderFiles(data) {
|
||
const browser = document.getElementById('fileBrowser');
|
||
const count = document.getElementById('fileCount');
|
||
const entries = (data && data.entries) || [];
|
||
count.textContent = entries.length ? `(${entries.length})` : '';
|
||
if (!entries.length) {
|
||
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
|
||
return;
|
||
}
|
||
browser.innerHTML = entries.map(e => {
|
||
const name = esc(e.name);
|
||
const date = new Date(e.mtime).toLocaleString();
|
||
const fullPath = currentDir ? currentDir + '/' + e.name : e.name;
|
||
const escPath = esc(fullPath);
|
||
if (e.type === 'dir') {
|
||
return `
|
||
<div class="row dir">
|
||
<div class="icon">📁</div>
|
||
<div>
|
||
<div class="name" title="${name}" data-open="${escPath}">${name}/</div>
|
||
<div class="meta">${date}</div>
|
||
</div>
|
||
<div class="meta">Ordner</div>
|
||
<button type="button" class="btn dl" data-zip="${escPath}">⬇ ZIP</button>
|
||
</div>`;
|
||
}
|
||
return `
|
||
<div class="row">
|
||
<div class="icon">📄</div>
|
||
<div>
|
||
<div class="name" title="${name}">${name}</div>
|
||
<div class="meta">${date}</div>
|
||
</div>
|
||
<div class="meta">${fmtSize(e.size)}</div>
|
||
<button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadFiles() {
|
||
const browser = document.getElementById('fileBrowser');
|
||
if (window._uploadInfo && window._uploadInfo.expired) {
|
||
document.querySelector('.browser').style.display = 'none';
|
||
return;
|
||
}
|
||
renderCrumbs();
|
||
try {
|
||
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||
const r = await fetch(url, { headers: authHeaders() });
|
||
if (r.status === 410) { document.querySelector('.browser').style.display = 'none'; return; }
|
||
if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; return; }
|
||
const data = await r.json();
|
||
lastFilesSig = JSON.stringify(data);
|
||
renderFiles(data);
|
||
} catch {
|
||
browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>';
|
||
}
|
||
}
|
||
|
||
async function streamDownload(url, suggestedName, btn) {
|
||
const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = '… lädt';
|
||
try {
|
||
const r = await fetch(url, { headers: authHeaders() });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const blob = await r.blob();
|
||
const objUrl = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = objUrl; a.download = suggestedName;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
|
||
} catch (ex) {
|
||
alert('Download fehlgeschlagen: ' + ex.message);
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = orig;
|
||
}
|
||
}
|
||
|
||
document.getElementById('crumbs').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-go]');
|
||
if (!btn) return;
|
||
currentDir = btn.dataset.go;
|
||
loadFiles();
|
||
});
|
||
|
||
document.getElementById('zipCurrent').addEventListener('click', (e) => {
|
||
const url = `/u/${token}/zip${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||
const name = (currentDir ? currentDir.split('/').pop() : 'alle-dateien') + '.zip';
|
||
streamDownload(url, name, e.currentTarget);
|
||
});
|
||
|
||
document.getElementById('fileBrowser').addEventListener('click', async (e) => {
|
||
const open = e.target.closest('[data-open]');
|
||
if (open) { currentDir = open.dataset.open; loadFiles(); return; }
|
||
const fileBtn = e.target.closest('button[data-path]');
|
||
if (fileBtn) {
|
||
const p = fileBtn.dataset.path;
|
||
streamDownload(`/u/${token}/file?path=${encodeURIComponent(p)}`, p.split('/').pop(), fileBtn);
|
||
return;
|
||
}
|
||
const zipBtn = e.target.closest('button[data-zip]');
|
||
if (zipBtn) {
|
||
const p = zipBtn.dataset.zip;
|
||
streamDownload(`/u/${token}/zip?dir=${encodeURIComponent(p)}`, p.split('/').pop() + '.zip', zipBtn);
|
||
return;
|
||
}
|
||
});
|
||
|
||
document.getElementById('pwBtn').onclick = async () => {
|
||
const pw = document.getElementById('pw').value;
|
||
const r = await fetch(`/u/${token}/auth`, {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ password: pw }),
|
||
});
|
||
const j = await r.json();
|
||
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; loadFiles(); startPolling(); }
|
||
else document.getElementById('pwErr').style.display='block';
|
||
};
|
||
|
||
function fmtSize(n) {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
|
||
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + ' MB';
|
||
return (n/1024/1024/1024).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function addRow(name, size) {
|
||
const row = document.createElement('div');
|
||
row.className = 'file-row';
|
||
row.innerHTML = `
|
||
<div style="font-size:1.1rem">📄</div>
|
||
<div>
|
||
<div class="name">${name.replace(/</g,'<')}</div>
|
||
<div class="meta">${fmtSize(size)}</div>
|
||
</div>
|
||
<div class="status">0 %</div>
|
||
<progress max="100" value="0"></progress>`;
|
||
list.appendChild(row);
|
||
return row;
|
||
}
|
||
|
||
async function uploadOne(file, relPath) {
|
||
const row = addRow(relPath, file.size);
|
||
const status = row.querySelector('.status');
|
||
const bar = row.querySelector('progress');
|
||
const fd = new FormData();
|
||
fd.append('path', relPath);
|
||
fd.append('file', file, file.name);
|
||
|
||
await new Promise((resolve) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', `/u/${token}/upload`);
|
||
if (password) xhr.setRequestHeader('X-Upload-Password', password);
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable) {
|
||
const pct = (e.loaded / e.total) * 100;
|
||
bar.value = pct; status.textContent = Math.round(pct) + ' %';
|
||
}
|
||
};
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
status.textContent = '✓'; bar.value = 100; row.classList.add('done');
|
||
} else { status.textContent = '✗'; row.classList.add('err'); }
|
||
resolve();
|
||
};
|
||
xhr.onerror = () => { status.textContent = '✗'; row.classList.add('err'); resolve(); };
|
||
xhr.send(fd);
|
||
});
|
||
}
|
||
|
||
async function uploadFiles(items) {
|
||
for (const { file, path } of items) await uploadOne(file, path);
|
||
loadFiles();
|
||
}
|
||
|
||
document.getElementById('fileInput').onchange = (e) => {
|
||
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.name })));
|
||
e.target.value = '';
|
||
};
|
||
document.getElementById('dirInput').onchange = (e) => {
|
||
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name })));
|
||
e.target.value = '';
|
||
};
|
||
|
||
async function traverse(entry, prefix='') {
|
||
const out = [];
|
||
if (entry.isFile) {
|
||
const file = await new Promise(r => entry.file(r));
|
||
out.push({ file, path: prefix + entry.name });
|
||
} else if (entry.isDirectory) {
|
||
const reader = entry.createReader();
|
||
const entries = await new Promise(r => reader.readEntries(r));
|
||
for (const e of entries) out.push(...await traverse(e, prefix + entry.name + '/'));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
|
||
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
||
drop.addEventListener('drop', async (e) => {
|
||
e.preventDefault(); drop.classList.remove('drag');
|
||
const all = [];
|
||
for (const it of e.dataTransfer.items) {
|
||
const entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
||
if (entry) all.push(...await traverse(entry));
|
||
else if (it.kind === 'file') { const f = it.getAsFile(); all.push({ file: f, path: f.name }); }
|
||
}
|
||
uploadFiles(all);
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|