Redesign UI and add configurable branding logo

- Modern dark/light theme with CSS variables, pills, cards and a sticky nav
- New Branding section in admin settings: upload logo (png/jpg/svg/webp/gif,
  2 MB) plus width/height sliders, proportional scale and reset
- Logo served publicly via /logo and displayed on admin portal and the
  customer upload page; files persist under ./data/logo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-16 11:24:45 +02:00
parent 0770259d3d
commit 4567e93aa2
5 changed files with 828 additions and 331 deletions

View File

@ -13,7 +13,7 @@ COPY src ./src
COPY public ./public
# Prepare mount points with UID 1000 ownership so named volumes inherit it.
RUN mkdir -p /data/db /data/uploads /webdav-config \
RUN mkdir -p /data/db /data/uploads /data/logo /webdav-config \
&& chown -R 1000:1000 /data /webdav-config /app
ENV NODE_ENV=production \

View File

@ -14,6 +14,7 @@ services:
volumes:
- ./data/db:/data/db # SQLite DB liegt hier neben docker-compose.yml
- ./data/uploads:/data/uploads # Kunden-Uploads
- ./data/logo:/data/logo # Admin-Logo
- webdav-config:/webdav-config # dynamisch generierte htpasswd + access.conf
webdav:

View File

@ -5,56 +5,221 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Adminportal</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 1150px; margin: 0 auto; padding: 1rem; }
header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: .5rem; margin-bottom: 1rem; }
h1 { margin: 0; font-size: 1.25rem; }
.tabs { display: flex; gap: .25rem; margin-bottom: 1rem; }
.tabs button { padding: .4rem .9rem; border: 1px solid #555; background: transparent; color: inherit; border-radius: 6px 6px 0 0; cursor: pointer; }
.tabs button.active { background: rgba(0,120,255,.15); border-color: #0078ff; }
input, select, button { padding: .45rem .55rem; border-radius: 6px; border: 1px solid #888; background: transparent; color: inherit; font: inherit; }
button.primary { background: #0078ff; color: #fff; border-color: #0078ff; cursor: pointer; }
button.danger { border-color: #e74c3c; color: #e74c3c; cursor: pointer; background: transparent; }
button { cursor: pointer; }
: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;
--danger: #ef4444;
--danger-hover: #f87171;
--success: #10b981;
--warn: #f59e0b;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 8px 32px rgba(0,0,0,.3);
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f8fafc;
--bg-raise: #ffffff;
--bg-card: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--text-muted: #475569;
--text-dim: #94a3b8;
--shadow: 0 4px 16px 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: var(--bg); color: var(--text); line-height: 1.5;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: .85em;
background: var(--bg-raise); padding: 2px 6px; border-radius: 4px; }
/* Layout */
.nav {
position: sticky; top: 0; z-index: 5;
background: var(--bg-raise); border-bottom: 1px solid var(--border);
padding: .75rem 1.5rem;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.nav-left { display: flex; align-items: center; gap: 1rem; }
.nav-logo { max-height: 40px; width: auto; object-fit: contain; }
.nav-title { font-weight: 600; font-size: 1.05rem; }
.nav-right { display: flex; align-items: center; gap: .75rem; font-size: .9rem; color: var(--text-muted); }
.container { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
/* Tabs */
.tabs {
display: flex; gap: .25rem; margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.tab {
padding: .6rem 1.1rem; border: none; background: transparent;
color: var(--text-muted); font: inherit; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--primary); font-weight: 500; }
/* Cards */
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow);
}
.card + .card { margin-top: 1rem; }
.card-title { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
.card-sub { color: var(--text-muted); font-size: .85rem; margin: 0 0 1rem; }
/* Forms */
label { display: block; font-size: .85rem; color: var(--text-muted); margin-bottom: .35rem; font-weight: 500; }
input, select, textarea {
width: 100%; padding: .55rem .75rem; font: inherit;
background: var(--bg); color: var(--text);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
input[type="file"] { padding: .35rem; }
input[type="range"] { padding: 0; border: none; background: transparent; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: 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, transform .05s;
white-space: nowrap;
}
.btn:hover { background: var(--bg); border-color: var(--primary); }
.btn:active { transform: translateY(1px); }
.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.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
.btn.ghost { border-color: transparent; color: var(--text-muted); }
.btn.ghost:hover { background: var(--bg); color: var(--text); }
.btn.sm { padding: .35rem .65rem; font-size: .85rem; }
/* Form grid */
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; align-items: end; }
.form-row .wide { grid-column: 1 / -1; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #444; vertical-align: top; font-size: .92rem; }
th { font-size: .78rem; text-transform: uppercase; color: #aaa; }
.actions button { margin-right: .2rem; }
code { font-size: .85rem; word-break: break-all; }
.small { font-size: .8rem; color: #999; }
form.inline { display: grid; grid-template-columns: repeat(5, auto) 1fr; gap: .4rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.center { display: flex; align-items: center; justify-content: center; min-height: 80vh; }
.card { border: 1px solid #555; border-radius: 10px; padding: 1.5rem; max-width: 420px; width: 100%; }
.card h2 { margin-top: 0; }
.card label { display: block; margin: .6rem 0 .2rem; font-size: .9rem; }
.card input { width: 100%; box-sizing: border-box; }
.err { color: #e74c3c; margin-top: .6rem; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; align-items: center; justify-content: center; z-index: 10; }
thead th {
text-align: left; padding: .7rem .8rem;
font-size: .75rem; text-transform: uppercase; letter-spacing: .03em;
color: var(--text-muted); font-weight: 600;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: .75rem .8rem; border-bottom: 1px solid var(--border);
vertical-align: middle; font-size: .92rem;
}
tbody tr:hover { background: color-mix(in srgb, var(--primary) 5%, transparent); }
tbody tr:last-child td { border-bottom: none; }
.cell-actions { text-align: right; white-space: nowrap; }
.cell-actions .btn { margin-left: .25rem; }
/* Pills */
.pill {
display: inline-flex; align-items: center; gap: .3rem;
padding: .12rem .55rem; border-radius: 999px;
font-size: .75rem; font-weight: 500; border: 1px solid var(--border-strong);
}
.pill.admin { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 50%, transparent);
background: color-mix(in srgb, var(--primary) 10%, transparent); }
.pill.staff { color: var(--text-muted); }
.pill.write { color: var(--success); border-color: color-mix(in srgb, var(--success) 50%, transparent);
background: color-mix(in srgb, var(--success) 10%, transparent); }
.pill.read { color: var(--text-muted); }
.pill.lock { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, transparent);
background: color-mix(in srgb, var(--warn) 10%, transparent); }
.pill.count { background: var(--bg-raise); color: var(--text-muted); }
.small { font-size: .8rem; color: var(--text-muted); }
.muted { color: var(--text-muted); }
/* Login/Setup center view */
.center { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
.auth-card { width: 100%; max-width: 420px; }
.auth-card h2 { margin: 0 0 .25rem; font-size: 1.4rem; }
.auth-card p.small { margin: 0 0 1.25rem; }
.auth-card .field + .field { margin-top: .85rem; }
.err { color: var(--danger); margin-top: .75rem; font-size: .88rem; min-height: 1em; }
.ok-msg { color: var(--success); font-size: .85rem; }
/* Modal */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); backdrop-filter: blur(3px);
display: none; align-items: center; justify-content: center; padding: 1rem; z-index: 20; }
.modal.open { display: flex; }
.modal-inner { background: var(--bg, #222); color: inherit; border: 1px solid #555; border-radius: 10px; padding: 1.25rem; max-width: 620px; width: 100%; max-height: 80vh; overflow: auto; }
@media (prefers-color-scheme: light) { .modal-inner { background: #fff; } }
.access-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: .5rem; align-items: center; padding: .3rem 0; border-bottom: 1px solid #333; }
.access-row label { display: inline-flex; gap: .3rem; align-items: center; font-size: .9rem; }
.muted-row td { color: #888; }
.pill { display: inline-block; padding: .1rem .45rem; border-radius: 999px; font-size: .75rem; border: 1px solid #888; }
.pill.admin { color: #0078ff; border-color: #0078ff; }
.pill.write { color: #2ecc71; border-color: #2ecc71; }
.pill.read { color: #888; }
.modal-inner {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.5rem; max-width: 560px; width: 100%; max-height: 85vh; overflow: auto;
box-shadow: var(--shadow);
}
.modal-inner h3 { margin-top: 0; }
.access-row {
display: grid; grid-template-columns: 1fr auto auto auto; gap: .5rem;
align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--border);
font-size: .92rem;
}
.access-row:last-child { border-bottom: none; }
.access-row label { display: inline-flex; gap: .3rem; align-items: center; margin: 0; cursor: pointer;
color: var(--text); font-weight: normal; font-size: .85rem; }
/* Logo upload widget */
.logo-preview {
display: flex; align-items: center; gap: 1rem; padding: 1rem;
border: 1px dashed var(--border-strong); border-radius: var(--radius-sm);
margin-bottom: .75rem; background: var(--bg);
}
.logo-preview img { max-height: 80px; max-width: 200px; object-fit: contain; border-radius: 4px; }
.logo-preview .placeholder {
width: 80px; height: 80px; border: 2px dashed var(--border-strong); border-radius: 8px;
display: grid; place-items: center; color: var(--text-dim); font-size: .75rem;
}
.range-row { display: flex; align-items: center; gap: .75rem; }
.range-row input[type=range] { flex: 1; }
.range-row .val { min-width: 3.5em; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); }
.copy-btn { cursor: pointer; }
</style>
</head>
<body>
<!-- SETUP VIEW -->
<div id="view-setup" class="center" style="display:none">
<div class="card">
<h2>Erstmal einrichten</h2>
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer anlegen.</p>
<div class="card auth-card">
<h2>Willkommen 👋</h2>
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer verwalten.</p>
<form id="setupForm">
<label>Benutzername</label>
<input name="username" required minlength="2" pattern="[A-Za-z0-9._-]+" />
<label>Passwort (min. 6 Zeichen)</label>
<input name="password" type="password" required minlength="6" />
<div style="margin-top:1rem"><button class="primary" type="submit">Admin anlegen & einloggen</button></div>
<div class="field"><label>Benutzername</label>
<input name="username" required pattern="[A-Za-z0-9._-]+" autocomplete="username" /></div>
<div class="field"><label>Passwort (min. 6 Zeichen)</label>
<input name="password" type="password" required minlength="6" autocomplete="new-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Admin anlegen & einloggen</button></div>
<div class="err" id="setupErr"></div>
</form>
</div>
@ -62,14 +227,15 @@
<!-- LOGIN VIEW -->
<div id="view-login" class="center" style="display:none">
<div class="card">
<div class="card auth-card">
<h2>Login</h2>
<p class="small">Melde dich mit deinem Account an.</p>
<form id="loginForm">
<label>Benutzername</label>
<input name="username" required autocomplete="username" />
<label>Passwort</label>
<input name="password" type="password" required autocomplete="current-password" />
<div style="margin-top:1rem"><button class="primary" type="submit">Einloggen</button></div>
<div class="field"><label>Benutzername</label>
<input name="username" required autocomplete="username" /></div>
<div class="field"><label>Passwort</label>
<input name="password" type="password" required autocomplete="current-password" /></div>
<div style="margin-top: 1rem"><button class="btn primary" type="submit" style="width:100%">Einloggen</button></div>
<div class="err" id="loginErr"></div>
</form>
</div>
@ -77,87 +243,149 @@
<!-- MAIN APP -->
<div id="view-app" style="display:none">
<header>
<h1>Adminportal</h1>
<div>
<span id="whoami" class="small"></span>
<button id="logoutBtn">Logout</button>
<nav class="nav">
<div class="nav-left">
<img id="navLogo" class="nav-logo" style="display:none" alt="Logo" />
<div class="nav-title">Adminportal</div>
</div>
</header>
<div class="nav-right">
<span id="whoami"></span>
<button id="logoutBtn" class="btn sm">Logout</button>
</div>
</nav>
<div class="tabs">
<button data-tab="customers" class="active">Kunden</button>
<button data-tab="users" id="tabUsersBtn">Benutzer</button>
<button data-tab="settings" id="tabSettingsBtn">Einstellungen</button>
</div>
<div class="container">
<div class="tabs">
<button class="tab active" data-tab="customers">Kunden</button>
<button class="tab" data-tab="users" id="tabUsersBtn">Benutzer</button>
<button class="tab" data-tab="settings" id="tabSettingsBtn">Einstellungen</button>
</div>
<!-- CUSTOMERS TAB -->
<section id="tab-customers">
<form id="createCustomerForm" class="inline" style="display:none">
<input name="name" placeholder="Kundenname" required />
<input name="password" placeholder="Passwort (optional)" />
<input name="expires_at" placeholder="Ablauf YYYY-MM-DD HH:MM" />
<button class="primary" type="submit">Kunden anlegen</button>
</form>
<table>
<thead>
<tr>
<th>Name</th><th>Slug</th><th>Upload-Link</th><th>PW</th><th>Ablauf</th><th>Uploads</th><th>Aktionen</th>
</tr>
</thead>
<tbody id="customerRows"></tbody>
</table>
<p class="small">WebDAV-Server: <code id="webdavUrl"></code> · Login mit eigenem Benutzer (Rollen-/ACL-abhängig).</p>
</section>
<!-- USERS TAB -->
<section id="tab-users" style="display:none">
<form id="createUserForm" class="inline">
<input name="username" placeholder="Benutzername" required pattern="[A-Za-z0-9._-]+" />
<input name="password" placeholder="Passwort (min. 6)" required minlength="6" />
<select name="role">
<option value="staff">Sachbearbeiter</option>
<option value="admin">Admin</option>
</select>
<button class="primary" type="submit">Benutzer anlegen</button>
</form>
<table>
<thead>
<tr><th>Benutzername</th><th>Rolle</th><th>Angelegt</th><th>Aktionen</th></tr>
</thead>
<tbody id="userRows"></tbody>
</table>
</section>
</div>
<!-- SETTINGS TAB -->
<section id="tab-settings" style="display:none">
<form id="settingsForm" style="max-width:600px">
<label style="display:block; margin-top:1rem">Öffentliche Basis-URL (für Kunden-Upload-Links)</label>
<input name="public_base_url" style="width:100%" placeholder="z. B. https://upload.example.com" />
<p class="small">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</p>
<label style="display:block; margin-top:1rem">Cron-Intervall (Minuten)</label>
<input name="janitor_interval_minutes" type="number" min="1" style="width:8rem" />
<p class="small">Periodischer DB-Abgleich mit dem Dateisystem: entfernt verwaiste DB-Einträge (via WebDAV gelöscht) und erfasst neu per WebDAV hochgeladene Dateien.</p>
<div style="margin-top:1rem; display:flex; gap:.5rem">
<button class="primary" type="submit">Speichern</button>
<button type="button" id="runJanitor">Abgleich jetzt ausführen</button>
<span id="janitorMsg" class="small"></span>
<!-- CUSTOMERS TAB -->
<section id="tab-customers">
<div class="card" id="createCustomerCard" style="display:none">
<h3 class="card-title">Neuen Kunden anlegen</h3>
<form id="createCustomerForm" class="form-row">
<div><label>Name</label><input name="name" required placeholder="Firma ABC" /></div>
<div><label>Passwort (optional)</label><input name="password" placeholder="Leer = offen" /></div>
<div><label>Ablaufdatum (optional)</label><input name="expires_at" placeholder="YYYY-MM-DD HH:MM" /></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
</form>
</div>
</form>
</section>
<div class="card" style="padding: 0; margin-top: 1rem">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kunde</th><th>Slug</th><th>Upload-Link</th><th>Schutz</th>
<th>Ablauf</th><th>Uploads</th><th></th>
</tr>
</thead>
<tbody id="customerRows"></tbody>
</table>
</div>
</div>
<p class="small" style="margin-top:.75rem">WebDAV-Server: <code id="webdavUrl"></code> — Login mit deinem eigenen Benutzer.</p>
</section>
<!-- USERS TAB -->
<section id="tab-users" style="display:none">
<div class="card">
<h3 class="card-title">Benutzer anlegen</h3>
<form id="createUserForm" class="form-row">
<div><label>Benutzername</label><input name="username" required pattern="[A-Za-z0-9._-]+" /></div>
<div><label>Passwort</label><input name="password" required minlength="6" type="text" /></div>
<div><label>Rolle</label>
<select name="role">
<option value="staff">Sachbearbeiter</option>
<option value="admin">Admin</option>
</select></div>
<div><label>&nbsp;</label><button class="btn primary" type="submit">Anlegen</button></div>
</form>
</div>
<div class="card" style="padding:0; margin-top:1rem">
<div class="table-wrap">
<table>
<thead>
<tr><th>Benutzername</th><th>Rolle</th><th>Angelegt</th><th></th></tr>
</thead>
<tbody id="userRows"></tbody>
</table>
</div>
</div>
</section>
<!-- SETTINGS TAB -->
<section id="tab-settings" style="display:none">
<div class="card">
<h3 class="card-title">Branding</h3>
<p class="card-sub">Logo wird im Adminportal und in der Kunden-Upload-Seite angezeigt.</p>
<div class="logo-preview">
<div id="logoPreviewBox">
<div class="placeholder">kein Logo</div>
</div>
<div style="flex:1">
<input type="file" id="logoFile" accept="image/png,image/jpeg,image/svg+xml,image/webp,image/gif" style="display:none" />
<button class="btn" id="logoUploadBtn" type="button">Logo hochladen</button>
<button class="btn danger" id="logoDeleteBtn" type="button" style="margin-left:.25rem">Entfernen</button>
<div class="small" style="margin-top:.5rem">Max 2 MB. PNG, JPG, SVG, WEBP, GIF.</div>
</div>
</div>
<div class="field"><label>Breite (px) — 0 = automatisch</label>
<div class="range-row">
<input type="range" id="logoWidth" min="0" max="600" step="5" />
<span class="val"><span id="logoWidthVal">0</span></span>
</div>
</div>
<div class="field"><label>Höhe (px) — 0 = automatisch</label>
<div class="range-row">
<input type="range" id="logoHeight" min="0" max="400" step="5" />
<span class="val"><span id="logoHeightVal">0</span></span>
</div>
</div>
<div class="field"><label>Proportionale Skalierung (%)</label>
<div class="range-row">
<input type="range" id="logoScale" min="25" max="300" step="5" value="100" />
<span class="val"><span id="logoScaleVal">100</span> %</span>
<button type="button" class="btn sm" id="logoReset">↺ Reset</button>
</div>
<p class="small" style="margin:.35rem 0 0">Setzt Breite & Höhe anhand der Originalmaße × Skalierung.</p>
</div>
</div>
<div class="card">
<h3 class="card-title">Allgemein</h3>
<form id="settingsForm">
<div class="field"><label>Öffentliche Basis-URL</label>
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
<p class="small" style="margin:.35rem 0 0">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</p>
</div>
<div class="field"><label>Cron-Intervall (Minuten)</label>
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
<p class="small" style="margin:.35rem 0 0">Periodischer DB/FS-Abgleich: entfernt verwaiste DB-Einträge, erfasst neu per WebDAV hochgeladene Dateien.</p>
</div>
<div style="margin-top: 1.25rem; display:flex; gap:.5rem; align-items:center">
<button class="btn primary" type="submit">Speichern</button>
<button class="btn" type="button" id="runJanitor">Abgleich jetzt ausführen</button>
<span class="ok-msg" id="settingsMsg"></span>
</div>
</form>
</div>
</section>
</div>
</div>
<!-- ACCESS MODAL -->
<div class="modal" id="accessModal">
<div class="modal-inner">
<h3 id="accessTitle">Zugriff verwalten</h3>
<p class="small">Wähle Sachbearbeiter aus, die auf die Kundendateien per WebDAV zugreifen dürfen.</p>
<p class="small" style="margin-top: 0">Wer darf per WebDAV auf die Kundendateien zugreifen?</p>
<div id="accessList"></div>
<div style="margin-top:1rem; display:flex; gap:.5rem; justify-content:flex-end">
<button id="accessCancel">Abbrechen</button>
<button id="accessSave" class="primary">Speichern</button>
<div style="margin-top:1.25rem; display:flex; gap:.5rem; justify-content:flex-end">
<button class="btn" id="accessCancel">Abbrechen</button>
<button class="btn primary" id="accessSave">Speichern</button>
</div>
</div>
</div>
@ -166,7 +394,7 @@
const api = {
async get(path) {
const r = await fetch(`/admin/api${path}`);
if (r.status === 401) { throw Object.assign(new Error('unauth'), { status: 401 }); }
if (r.status === 401) throw Object.assign(new Error('unauth'), { status: 401 });
return r.json();
},
async send(method, path, body) {
@ -175,19 +403,25 @@ const api = {
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(data.error || r.statusText), { status: r.status, data });
if (!r.ok) throw Object.assign(new Error(data.error || r.statusText), { status: r.status });
return data;
},
async upload(path, file, field = 'file') {
const fd = new FormData();
fd.append(field, file);
const r = await fetch(`/admin/api${path}`, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(data.error || r.statusText), { status: r.status });
return data;
},
};
let me = null;
function show(view) {
for (const id of ['view-setup','view-login','view-app']) {
document.getElementById(id).style.display = id === view ? '' : 'none';
}
}
function fmtSize(n) {
if (!n) return '0 B';
if (n < 1024) return n + ' B';
@ -196,53 +430,72 @@ function fmtSize(n) {
return (n/1024/1024/1024).toFixed(2) + ' GB';
}
function parseDate(s) { if (!s) return null; const t = Date.parse(String(s).replace(' ','T')); return isNaN(t)?null:t; }
function esc(s) { return String(s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
let naturalW = 0, naturalH = 0;
function applySizeStyle(el, w, h) {
el.style.width = w > 0 ? w + 'px' : '';
el.style.height = h > 0 ? h + 'px' : '';
if (!w && !h) { el.style.maxHeight = '40px'; el.style.maxWidth = '240px'; }
else { el.style.maxHeight = ''; el.style.maxWidth = ''; }
}
async function applyLogo(width, height) {
const img = document.getElementById('navLogo');
const src = '/logo?t=' + Date.now();
const r = await fetch(src, { method: 'HEAD' });
if (r.ok) { img.src = src; img.style.display = ''; applySizeStyle(img, width, height); }
else img.style.display = 'none';
}
function measureNatural() {
return new Promise((resolve) => {
const probe = new Image();
probe.onload = () => { naturalW = probe.naturalWidth; naturalH = probe.naturalHeight; resolve(); };
probe.onerror = () => { naturalW = naturalH = 0; resolve(); };
probe.src = '/logo?t=' + Date.now();
});
}
async function bootstrap() {
const status = await api.get('/status');
const [status, branding] = await Promise.all([api.get('/status'), api.get('/branding')]);
await measureNatural();
applyLogo(branding.logo_width_px, branding.logo_height_px);
if (status.setup_required) return show('view-setup');
if (!status.authenticated) return show('view-login');
me = status.user;
document.getElementById('whoami').textContent = `${me.username} (${me.role})`;
document.getElementById('whoami').textContent = `${me.username} · ${me.role}`;
if (me.role !== 'admin') {
document.getElementById('tabUsersBtn').style.display = 'none';
document.getElementById('tabSettingsBtn').style.display = 'none';
document.getElementById('createCustomerForm').style.display = 'none';
document.getElementById('createCustomerCard').style.display = 'none';
} else {
document.getElementById('createCustomerForm').style.display = '';
document.getElementById('createCustomerCard').style.display = '';
}
document.getElementById('webdavUrl').textContent = `webdav://${location.hostname}:1900/`;
show('view-app');
loadCustomers();
}
// --- Setup ---
// Setup
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('POST', '/setup', Object.fromEntries(fd.entries()));
location.reload();
} catch (ex) { document.getElementById('setupErr').textContent = ex.message; }
try { await api.send('POST', '/setup', Object.fromEntries(new FormData(e.target))); location.reload(); }
catch (ex) { document.getElementById('setupErr').textContent = ex.message; }
});
// --- Login / Logout ---
// Login
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('POST', '/login', Object.fromEntries(fd.entries()));
location.reload();
} catch (ex) { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.send('POST', '/logout');
location.reload();
try { await api.send('POST', '/login', Object.fromEntries(new FormData(e.target))); location.reload(); }
catch { document.getElementById('loginErr').textContent = 'Login fehlgeschlagen'; }
});
document.getElementById('logoutBtn').onclick = async () => { await api.send('POST', '/logout'); location.reload(); };
// --- Tabs ---
document.querySelectorAll('.tabs button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tabs button').forEach(b => b.classList.remove('active'));
// Tabs
document.querySelectorAll('.tab').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('tab-customers').style.display = tab === 'customers' ? '' : 'none';
@ -250,84 +503,76 @@ document.querySelectorAll('.tabs button').forEach(btn => {
document.getElementById('tab-settings').style.display = tab === 'settings' ? '' : 'none';
if (tab === 'users') loadUsers();
if (tab === 'settings') loadSettings();
});
};
});
// --- Customers ---
// Customers
async function loadCustomers() {
const data = await api.get('/customers');
const rows = document.getElementById('customerRows');
const adminOnly = me.role === 'admin';
rows.innerHTML = '';
if (!data.length) {
rows.innerHTML = `<tr><td colspan="7" class="small" style="padding:1.5rem; text-align:center">Noch keine Kunden.</td></tr>`;
return;
}
for (const c of data) {
const link = c.upload_url || '';
const tr = document.createElement('tr');
const link = c.upload_url || '';
const adminOnly = me.role === 'admin';
tr.innerHTML = `
<td>${c.name}</td>
<td><code>${c.slug}</code></td>
<td>${adminOnly ? `<code>${link}</code> <div><button data-copy="${link}">Kopieren</button></div>` : '<span class="small">nur Admin</span>'}</td>
<td>${c.has_password ? '🔒' : ''}</td>
<td>${c.expires_at ? new Date(c.expires_at).toLocaleString() : ''}</td>
<td>${c.upload_count} <span class="small">(${fmtSize(c.total_size)})</span></td>
<td class="actions">
${adminOnly ? `
<button data-access="${c.id}" data-slug="${c.slug}" data-name="${c.name}">Zugriff</button>
<button data-edit="${c.id}">Bearbeiten</button>
<button data-regen="${c.id}">Neuer Link</button>
<button class="danger" data-del="${c.id}">Löschen</button>
` : `<span class="pill ${c.my_access}">${c.my_access}</span>`}
</td>
`;
<td><strong>${esc(c.name)}</strong></td>
<td><code>${esc(c.slug)}</code></td>
<td>${adminOnly
? `<div style="display:flex; gap:.35rem; align-items:center"><code style="max-width:26ch; overflow:hidden; text-overflow:ellipsis">${esc(link)}</code><button class="btn sm copy-btn" data-copy="${esc(link)}">📋</button></div>`
: '<span class="small"></span>'}</td>
<td>${c.has_password ? '<span class="pill lock">🔒 PW</span>' : '<span class="small"></span>'}</td>
<td class="small">${c.expires_at ? new Date(c.expires_at).toLocaleString() : ''}</td>
<td><span class="pill count">${c.upload_count} · ${fmtSize(c.total_size)}</span></td>
<td class="cell-actions">${adminOnly ? `
<button class="btn sm" data-access="${c.id}" data-name="${esc(c.name)}">Zugriff</button>
<button class="btn sm" data-edit="${c.id}">Bearbeiten</button>
<button class="btn sm" data-regen="${c.id}">Neuer Link</button>
<button class="btn sm danger" data-del="${c.id}">Löschen</button>
` : `<span class="pill ${c.my_access}">${c.my_access}</span>`}</td>`;
rows.appendChild(tr);
}
}
document.getElementById('customerRows').addEventListener('click', async (e) => {
const t = e.target;
const t = e.target.closest('button'); if (!t) return;
if (t.dataset.copy) {
navigator.clipboard.writeText(t.dataset.copy);
const orig = t.textContent; t.textContent = 'Kopiert!';
setTimeout(() => t.textContent = orig, 1200);
const orig = t.textContent; t.textContent = '✓'; setTimeout(() => t.textContent = orig, 1200);
}
if (t.dataset.del) {
if (!confirm('Kunde wirklich löschen? (Hochgeladene Dateien bleiben auf Disk.)')) return;
await api.send('DELETE', `/customers/${t.dataset.del}`);
loadCustomers();
if (t.dataset.del && confirm('Kunde wirklich löschen? (Dateien bleiben auf Disk.)')) {
await api.send('DELETE', `/customers/${t.dataset.del}`); loadCustomers();
}
if (t.dataset.regen) {
if (!confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) return;
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`);
loadCustomers();
if (t.dataset.regen && confirm('Neuen Link erzeugen? Alter Link wird ungültig.')) {
await api.send('POST', `/customers/${t.dataset.regen}/regenerate-token`); loadCustomers();
}
if (t.dataset.edit) {
const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', '');
if (pw === null) return;
const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', '');
if (exp === null) return;
const pw = prompt('Neues Passwort (leer = unverändert, "-" = entfernen):', ''); if (pw === null) return;
const exp = prompt('Neuer Ablauf (YYYY-MM-DD HH:MM, leer = unverändert, "-" = entfernen):', ''); if (exp === null) return;
const body = {};
if (pw === '-') body.clear_password = true;
else if (pw) body.password = pw;
if (exp === '-') body.expires_at = null;
else if (exp) body.expires_at = parseDate(exp);
await api.send('PATCH', `/customers/${t.dataset.edit}`, body);
loadCustomers();
if (pw === '-') body.clear_password = true; else if (pw) body.password = pw;
if (exp === '-') body.expires_at = null; else if (exp) body.expires_at = parseDate(exp);
await api.send('PATCH', `/customers/${t.dataset.edit}`, body); loadCustomers();
}
if (t.dataset.access) openAccessModal(t.dataset.access, t.dataset.name);
});
document.getElementById('createCustomerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = {
name: fd.get('name'),
password: fd.get('password') || undefined,
expires_at: parseDate(fd.get('expires_at')) || undefined,
};
try { await api.send('POST', '/customers', body); e.target.reset(); loadCustomers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
try {
await api.send('POST', '/customers', {
name: fd.get('name'),
password: fd.get('password') || undefined,
expires_at: parseDate(fd.get('expires_at')) || undefined,
});
e.target.reset(); loadCustomers();
} catch (ex) { alert('Fehler: ' + ex.message); }
});
// --- Users ---
// Users
async function loadUsers() {
const data = await api.get('/users');
const rows = document.getElementById('userRows');
@ -335,51 +580,143 @@ async function loadUsers() {
for (const u of data) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.username}${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : ''}">${u.role}</span></td>
<td><strong>${esc(u.username)}</strong>${u.id === me.id ? ' <span class="small">(du)</span>' : ''}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'staff'}">${u.role}</span></td>
<td class="small">${new Date(u.created_at).toLocaleDateString()}</td>
<td class="actions">
<button data-pw="${u.id}">Passwort</button>
<button data-role="${u.id}" data-current="${u.role}">Rolle</button>
<button class="danger" data-deluser="${u.id}" data-name="${u.username}">Löschen</button>
</td>
`;
<td class="cell-actions">
<button class="btn sm" data-pw="${u.id}">Passwort</button>
<button class="btn sm" data-role="${u.id}" data-current="${u.role}">Rolle</button>
<button class="btn sm danger" data-deluser="${u.id}" data-name="${esc(u.username)}">Löschen</button>
</td>`;
rows.appendChild(tr);
}
}
document.getElementById('userRows').addEventListener('click', async (e) => {
const t = e.target;
if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 6 Zeichen):', '');
if (!pw) return;
try { await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
}
if (t.dataset.role) {
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
try { await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); loadUsers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
}
if (t.dataset.deluser) {
if (!confirm(`Benutzer "${t.dataset.name}" löschen?`)) return;
try { await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
}
const t = e.target.closest('button'); if (!t) return;
try {
if (t.dataset.pw) {
const pw = prompt('Neues Passwort (min. 6 Zeichen):', ''); if (!pw) return;
await api.send('PATCH', `/users/${t.dataset.pw}`, { password: pw }); loadUsers();
}
if (t.dataset.role) {
const newRole = t.dataset.current === 'admin' ? 'staff' : 'admin';
if (!confirm(`Rolle auf "${newRole}" ändern?`)) return;
await api.send('PATCH', `/users/${t.dataset.role}`, { role: newRole }); loadUsers();
}
if (t.dataset.deluser && confirm(`Benutzer "${t.dataset.name}" löschen?`)) {
await api.send('DELETE', `/users/${t.dataset.deluser}`); loadUsers();
}
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('createUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
try { await api.send('POST', '/users', Object.fromEntries(new FormData(e.target))); e.target.reset(); loadUsers(); }
catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('createUserForm').addEventListener('submit', async (e) => {
// Settings
async function loadSettings() {
await measureNatural();
const s = await api.get('/settings');
const form = document.getElementById('settingsForm');
form.public_base_url.value = s.public_base_url || '';
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
setSlider('logoWidth', s.logo_width_px || 0);
setSlider('logoHeight', s.logo_height_px || 0);
updateScaleFromWH();
updateLogoPreview(s.logo_filename);
}
function setSlider(id, val) {
document.getElementById(id).value = val;
document.getElementById(id + 'Val').textContent = val;
}
function getSlider(id) { return parseInt(document.getElementById(id).value, 10) || 0; }
function updateScaleFromWH() {
if (!naturalW || !naturalH) return;
const w = getSlider('logoWidth');
const h = getSlider('logoHeight');
let pct = 100;
if (w > 0) pct = Math.round((w / naturalW) * 100);
else if (h > 0) pct = Math.round((h / naturalH) * 100);
setSlider('logoScale', Math.max(25, Math.min(300, pct)));
}
function updateLogoPreview(filename) {
const box = document.getElementById('logoPreviewBox');
if (filename) {
const w = getSlider('logoWidth'), h = getSlider('logoHeight');
box.innerHTML = `<img id="previewLogo" src="/logo?t=${Date.now()}" alt="Logo"/>`;
applySizeStyle(box.querySelector('img'), w || 0, h || 0);
if (!w && !h) { const im = box.querySelector('img'); im.style.maxHeight = '80px'; im.style.maxWidth = '240px'; }
} else {
box.innerHTML = `<div class="placeholder">kein Logo</div>`;
}
applyLogo(getSlider('logoWidth'), getSlider('logoHeight'));
}
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('POST', '/users', Object.fromEntries(fd.entries()));
e.target.reset();
loadUsers();
await api.send('PUT', '/settings', {
public_base_url: fd.get('public_base_url') || '',
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
logo_width_px: getSlider('logoWidth'),
logo_height_px: getSlider('logoHeight'),
});
const msg = document.getElementById('settingsMsg');
msg.textContent = '✓ Gespeichert';
setTimeout(() => msg.textContent = '', 2000);
applyLogo(getSlider('logoWidth'), getSlider('logoHeight'));
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('runJanitor').addEventListener('click', async () => {
const msg = document.getElementById('settingsMsg'); msg.textContent = '… läuft';
try { const r = await api.send('POST', '/janitor/run'); msg.textContent = `✓ +${r.added} / -${r.removed}`; }
catch (ex) { msg.textContent = 'Fehler: ' + ex.message; }
});
document.getElementById('logoWidth').addEventListener('input', (e) => {
document.getElementById('logoWidthVal').textContent = e.target.value;
updateScaleFromWH();
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoHeight').addEventListener('input', (e) => {
document.getElementById('logoHeightVal').textContent = e.target.value;
updateScaleFromWH();
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoScale').addEventListener('input', (e) => {
const pct = parseInt(e.target.value, 10);
document.getElementById('logoScaleVal').textContent = pct;
if (naturalW && naturalH) {
setSlider('logoWidth', Math.round(naturalW * pct / 100));
setSlider('logoHeight', Math.round(naturalH * pct / 100));
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
}
});
document.getElementById('logoReset').addEventListener('click', () => {
setSlider('logoWidth', 0);
setSlider('logoHeight', 0);
setSlider('logoScale', 100);
updateLogoPreview(document.querySelector('#logoPreviewBox img') ? 'yes' : '');
});
document.getElementById('logoUploadBtn').onclick = () => document.getElementById('logoFile').click();
document.getElementById('logoFile').onchange = async (e) => {
const f = e.target.files[0]; if (!f) return;
try {
await api.upload('/logo', f, 'logo');
await measureNatural();
const s = await api.get('/settings');
updateLogoPreview(s.logo_filename);
} catch (ex) { alert('Fehler: ' + ex.message); }
};
document.getElementById('logoDeleteBtn').onclick = async () => {
if (!confirm('Logo entfernen?')) return;
await api.send('DELETE', '/logo');
naturalW = naturalH = 0;
updateLogoPreview('');
};
// --- Access Modal ---
// Access modal
let accessCustomerId = null;
async function openAccessModal(id, name) {
accessCustomerId = id;
@ -391,12 +728,11 @@ async function openAccessModal(id, name) {
} else {
list.innerHTML = staff.map(s => `
<div class="access-row">
<div>${s.username}</div>
<div><strong>${esc(s.username)}</strong></div>
<label><input type="radio" name="acc-${s.user_id}" value="" ${!s.access?'checked':''}> kein</label>
<label><input type="radio" name="acc-${s.user_id}" value="read" ${s.access==='read'?'checked':''}> lesen</label>
<label><input type="radio" name="acc-${s.user_id}" value="write" ${s.access==='write'?'checked':''}> schreiben</label>
</div>
`).join('');
</div>`).join('');
}
document.getElementById('accessModal').classList.add('open');
}
@ -406,8 +742,7 @@ document.getElementById('accessSave').onclick = async () => {
document.querySelectorAll('#accessList .access-row').forEach(row => {
const picked = row.querySelector('input[type=radio]:checked');
if (!picked || !picked.value) return;
const uid = picked.name.replace('acc-', '');
entries.push({ user_id: parseInt(uid, 10), access: picked.value });
entries.push({ user_id: parseInt(picked.name.replace('acc-',''), 10), access: picked.value });
});
try {
await api.send('PUT', `/customers/${accessCustomerId}/access`, { access: entries });
@ -415,34 +750,6 @@ document.getElementById('accessSave').onclick = async () => {
} catch (ex) { alert('Fehler: ' + ex.message); }
};
// --- Settings ---
async function loadSettings() {
const s = await api.get('/settings');
const form = document.getElementById('settingsForm');
form.public_base_url.value = s.public_base_url || '';
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
}
document.getElementById('settingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.send('PUT', '/settings', {
public_base_url: fd.get('public_base_url') || '',
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
});
document.getElementById('janitorMsg').textContent = 'Gespeichert.';
setTimeout(() => document.getElementById('janitorMsg').textContent = '', 2000);
} catch (ex) { alert('Fehler: ' + ex.message); }
});
document.getElementById('runJanitor').addEventListener('click', async () => {
const msg = document.getElementById('janitorMsg');
msg.textContent = 'läuft …';
try {
const r = await api.send('POST', '/janitor/run');
msg.textContent = `fertig: +${r.added} hinzugefügt, -${r.removed} entfernt`;
} catch (ex) { msg.textContent = 'Fehler: ' + ex.message; }
});
bootstrap().catch(e => {
document.getElementById('loginErr').textContent = 'Fehler: ' + e.message;
show('view-login');

View File

@ -5,53 +5,164 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Datei-Upload</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: .25rem; }
.muted { color: #888; font-size: .9rem; }
: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 {
margin-top: 1.5rem; border: 2px dashed #888; border-radius: 12px;
padding: 3rem 1rem; text-align: center; transition: background .15s, border-color .15s;
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.drag { background: rgba(0,120,255,.08); border-color: #0078ff; }
.buttons { margin-top: 1rem; display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button, label.btn {
padding: .6rem 1rem; border-radius: 8px; border: 1px solid #888;
cursor: pointer; background: transparent; font: inherit;
.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);
}
label.btn input { display: none; }
#list { margin-top: 1.5rem; }
.file { display: flex; justify-content: space-between; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid #333; font-size: .9rem; }
.file .status { font-variant-numeric: tabular-nums; }
.ok { color: #2ecc71; }
.err { color: #e74c3c; }
.gate { margin-top: 1rem; display: none; }
.gate input { padding: .5rem; border-radius: 6px; border: 1px solid #888; background: transparent; color: inherit; }
progress { width: 100%; height: 8px; }
.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); }
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>
<div class="muted" id="info"></div>
<p class="subtitle" id="info"></p>
<div class="gate" id="gate">
<p>Dieser Link ist passwortgeschützt.</p>
<input type="password" id="pw" placeholder="Passwort" />
<button id="pwBtn">Entsperren</button>
<div id="pwErr" class="err" style="display:none;margin-top:.5rem">Passwort falsch.</div>
<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>Dateien oder Ordner hier hineinziehen</div>
<div class="muted">oder</div>
<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">Dateien wählen<input type="file" id="fileInput" multiple /></label>
<label class="btn">Ordner wählen<input type="file" id="dirInput" webkitdirectory multiple /></label>
<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>
</div>
<footer id="footer"></footer>
<script>
const token = location.pathname.split('/').filter(Boolean)[1];
@ -63,13 +174,35 @@ 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 {}
}
async function init() {
await applyBranding();
const r = await fetch(`/u/${token}/info`);
if (!r.ok) { document.body.innerHTML = '<h1>Link ungültig oder abgelaufen.</h1>'; return; }
if (!r.ok) {
document.querySelector('.card').innerHTML =
'<h1>Link ungültig oder abgelaufen</h1><p class="subtitle">Bitte wende dich an deinen Ansprechpartner für einen neuen Link.</p>';
return;
}
const data = await r.json();
document.getElementById('title').textContent = `Upload für ${data.name}`;
if (data.expires_at) {
info.textContent = `Gültig bis: ${new Date(data.expires_at).toLocaleString()}`;
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.';
}
if (data.has_password) gate.style.display = 'block';
else main.style.display = 'block';
@ -95,18 +228,24 @@ function fmtSize(n) {
function addRow(name, size) {
const row = document.createElement('div');
row.className = 'file';
row.innerHTML = `<div>${name}</div><div class="status">${fmtSize(size)} <span>wartet</span><progress max="100" value="0"></progress></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 span');
const status = row.querySelector('.status');
const bar = row.querySelector('progress');
const fd = new FormData();
// path first, so multer has it available when processing the file
fd.append('path', relPath);
fd.append('file', file, file.name);
@ -115,40 +254,35 @@ async function uploadOne(file, relPath) {
xhr.open('POST', `/u/${token}/upload`);
if (password) xhr.setRequestHeader('X-Upload-Password', password);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
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 = 'fertig';
status.className = 'ok';
bar.value = 100;
} else {
status.textContent = 'Fehler';
status.className = 'err';
}
status.textContent = '✓'; bar.value = 100; row.classList.add('done');
} else { status.textContent = '✗'; row.classList.add('err'); }
resolve();
};
xhr.onerror = () => { status.textContent='Fehler'; status.className='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);
}
for (const { file, path } of items) await uploadOne(file, path);
}
document.getElementById('fileInput').onchange = (e) => {
const items = [...e.target.files].map(f => ({ file: f, path: f.name }));
uploadFiles(items);
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.name })));
e.target.value = '';
};
document.getElementById('dirInput').onchange = (e) => {
const items = [...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name }));
uploadFiles(items);
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name })));
e.target.value = '';
};
// Drag & drop with directory support
async function traverse(entry, prefix='') {
const out = [];
if (entry.isFile) {
@ -157,10 +291,7 @@ async function traverse(entry, prefix='') {
} else if (entry.isDirectory) {
const reader = entry.createReader();
const entries = await new Promise(r => reader.readEntries(r));
for (const e of entries) {
const sub = await traverse(e, prefix + entry.name + '/');
out.push(...sub);
}
for (const e of entries) out.push(...await traverse(e, prefix + entry.name + '/'));
}
return out;
}
@ -168,19 +299,12 @@ async function traverse(entry, prefix='') {
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 items = [...e.dataTransfer.items];
e.preventDefault(); drop.classList.remove('drag');
const all = [];
for (const it of items) {
for (const it of e.dataTransfer.items) {
const entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
if (entry) {
const sub = await traverse(entry);
all.push(...sub);
} else if (it.kind === 'file') {
const f = it.getAsFile();
all.push({ file: f, path: f.name });
}
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);
});

View File

@ -12,7 +12,9 @@ const janitor = require('./janitor');
const PORT = parseInt(process.env.PORT || '3000', 10);
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads';
const LOGO_DIR = process.env.LOGO_DIR || '/data/logo';
fs.mkdirSync(LOGO_DIR, { recursive: true });
settings.seedFromEnv();
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
@ -83,6 +85,14 @@ function canAccessCustomer(user, customer, needWrite = false) {
// ---------- Setup & Auth API ----------
const publicApi = express.Router();
publicApi.get('/branding', (req, res) => {
res.json({
logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
});
});
publicApi.get('/status', (req, res) => {
const u = auth.getSessionUser(req);
res.json({
@ -189,11 +199,14 @@ api.get('/settings', auth.requireAdmin, (req, res) => {
res.json({
public_base_url: settings.get('public_base_url', ''),
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
});
});
api.put('/settings', auth.requireAdmin, (req, res) => {
const { public_base_url, janitor_interval_minutes } = req.body || {};
const { public_base_url, janitor_interval_minutes, logo_width_px, logo_height_px } = req.body || {};
if (public_base_url !== undefined) {
const v = String(public_base_url || '').trim().replace(/\/+$/, '');
settings.set('public_base_url', v);
@ -203,6 +216,48 @@ api.put('/settings', auth.requireAdmin, (req, res) => {
settings.set('janitor_interval_minutes', String(n));
janitor.restart(n * 60 * 1000);
}
if (logo_width_px !== undefined) {
const n = Math.max(0, Math.min(800, parseInt(logo_width_px, 10) || 0));
settings.set('logo_width_px', String(n));
}
if (logo_height_px !== undefined) {
const n = Math.max(0, Math.min(600, parseInt(logo_height_px, 10) || 0));
settings.set('logo_height_px', String(n));
}
res.json({ ok: true });
});
// --- Logo (admin manages, public serves) ---
const logoUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ok = /^image\/(png|jpeg|gif|svg\+xml|webp)$/.test(file.mimetype);
cb(ok ? null : new Error('invalid image type'), ok);
},
});
api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'image/webp': '.webp',
}[req.file.mimetype] || '';
const oldFn = settings.get('logo_filename', '');
if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} }
const fn = `logo${ext}`;
fs.writeFileSync(path.join(LOGO_DIR, fn), req.file.buffer);
settings.set('logo_filename', fn);
res.json({ ok: true, filename: fn });
});
api.delete('/logo', auth.requireAdmin, (req, res) => {
const fn = settings.get('logo_filename', '');
if (fn) { try { fs.unlinkSync(path.join(LOGO_DIR, fn)); } catch {} }
settings.set('logo_filename', '');
res.json({ ok: true });
});
@ -420,6 +475,16 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
});
// Public logo (no auth) — used by admin & upload page
app.get('/logo', (req, res) => {
const fn = settings.get('logo_filename', '');
if (!fn) return res.status(404).end();
const fp = path.join(LOGO_DIR, fn);
if (!fs.existsSync(fp)) return res.status(404).end();
res.setHeader('Cache-Control', 'public, max-age=60');
res.sendFile(fp);
});
// ---------- Root ----------
app.get('/', (req, res) => res.redirect('/admin/'));