simple-web-file-upload/public/upload.html

555 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[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,'&lt;')}</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>