Files
IT-Nexus/frontend/public/share-tool.html
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen)
- JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen
- Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl)
- WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie
- Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt
- XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base)
- E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar)
- SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen)
- TV-Dashboard: Shared-Key statt komplett offenem Endpoint
- Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen
- Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json
- FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:27:17 +02:00

1728 lines
59 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="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cereda Secure Share</title>
<style>
/* ============================================================
CEREDA · SECURE SHARE
Brand: Cereda Systems (Lüdenscheid) — signature accent red
Design language: Apple.com — large type, generous whitespace,
glassmorphism, soft motion.
============================================================ */
:root {
/* Cereda palette */
--teal: #3fa3a3;
--teal-deep: #008487;
--teal-soft: #E8F4F4;
/* Neutrals — light (default) */
--ink: #1d1d1f;
--ink-2: #424245;
--muted: #6e6e73;
--hair: #d2d2d7;
--hair-soft: #e8e8ed;
--canvas: #fbfbfd;
--canvas-2: #f5f5f7;
--white: #ffffff;
--surface: #ffffff;
--surface-2: #fbfbfd;
--top-bg: rgba(251, 251, 253, 0.72);
--card-bg: rgba(255, 255, 255, 0.7);
--card-sheen: linear-gradient(180deg, rgba(255,255,255,0.5), transparent);
--shadow-card: 0 1px 0 rgba(255,255,255,0.6) inset, 0 1px 2px rgba(0,0,0,0.02), 0 12px 40px -20px rgba(0,0,0,0.08);
--shadow-seg: 0 1px 2px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(0,0,0,0.04);
--glow-1: rgba(63,163,163,0.06);
--glow-2: rgba(63,163,163,0.04);
--row-hover: rgba(245,245,247,0.7);
/* States */
--ok: #34a14a;
--ok-soft: #ecf7ee;
--ok-border: #c8e7cd;
--ok-chip-border: #d9ecdc;
/* Type */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
/* Motion */
--ease: cubic-bezier(0.4, 0.0, 0.2, 1);
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-theme="dark"] {
--ink: #f5f5f7;
--ink-2: #c7c7cc;
--muted: #8e8e93;
--hair: #2e2e33;
--hair-soft: #232328;
--canvas: #0a0a0c;
--canvas-2: #15151a;
--white: #1c1c20;
--surface: #1c1c20;
--surface-2: #15151a;
--top-bg: rgba(14, 14, 18, 0.72);
--card-bg: rgba(28, 28, 32, 0.62);
--card-sheen: linear-gradient(180deg, rgba(255,255,255,0.04), transparent);
--shadow-card: 0 1px 0 rgba(255,255,255,0.05) inset, 0 1px 2px rgba(0,0,0,0.4), 0 18px 50px -22px rgba(0,0,0,0.6);
--shadow-seg: 0 1px 2px rgba(0,0,0,0.5), 0 0 0 0.5px rgba(255,255,255,0.06);
--teal-soft: rgba(63,163,163,0.18);
--glow-1: rgba(63,163,163,0.10);
--glow-2: rgba(63,163,163,0.07);
--row-hover: rgba(255,255,255,0.04);
--ok-soft: rgba(52,161,74,0.15);
--ok-border: rgba(52,161,74,0.35);
--ok-chip-border: rgba(52,161,74,0.25);
}
/* Theme transitions */
html, body { transition: background-color 0.5s var(--ease), color 0.5s var(--ease); }
.top, .card, .login-card, .input, .textarea, .select, .seg, .seg button.on,
.user-pill, .row-ico, .badge, .drop, .row, .btn-ms, .btn, .demo-badge,
.success-box, .copy-chip, .status-chip {
transition: background-color 0.4s var(--ease), color 0.4s var(--ease),
border-color 0.4s var(--ease), box-shadow 0.4s var(--ease);
}
* { box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font-sans);
color: var(--ink);
background: var(--white);
font-size: 17px;
line-height: 1.47;
min-height: 100vh;
font-weight: 400;
letter-spacing: -0.01em;
}
::selection { background: rgba(63,163,163,0.18); }
button, input, textarea, select { font: inherit; color: inherit; }
/* ============================================================
LOGO (inline mark + wordmark — interpretation of the
Cereda Systems logo file)
============================================================ */
.logo { display: inline-flex; align-items: center; gap: 10px; }
.logo-img {
display: block;
height: 30px;
width: auto;
user-select: none;
-webkit-user-drag: none;
}
.logo-divider {
width: 1px;
height: 18px;
background: var(--hair);
margin: 0 2px;
}
.logo-sub {
font-size: 13px;
font-weight: 400;
color: var(--muted);
letter-spacing: -0.005em;
}
/* ============================================================
BUTTONS
============================================================ */
.btn {
appearance: none;
border: 1px solid transparent;
background: var(--canvas-2);
color: var(--ink);
font-family: inherit;
font-size: 15px;
font-weight: 500;
padding: 11px 20px;
border-radius: 980px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
line-height: 1;
transition: transform 0.22s var(--ease-out), background 0.22s var(--ease), border-color 0.22s var(--ease), color 0.22s var(--ease), box-shadow 0.22s var(--ease);
letter-spacing: -0.01em;
white-space: nowrap;
}
.btn:hover { transform: scale(1.02); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; }
.btn-primary {
background: var(--teal);
color: white;
box-shadow: 0 1px 0 rgba(255,255,255,0.18) inset, 0 6px 16px -8px rgba(63,163,163,0.4);
}
.btn-primary:hover { background: var(--teal-deep); }
.btn-ghost {
background: transparent;
color: var(--ink);
}
.btn-ghost:hover { background: var(--canvas-2); }
.btn-link {
background: transparent;
color: var(--teal);
padding: 6px 10px;
font-weight: 500;
}
.btn-link:hover { background: var(--teal-soft); }
.btn-icon {
width: 36px; height: 36px;
padding: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
}
.btn-icon:hover { background: var(--canvas-2); color: var(--ink); }
.btn-icon.danger:hover { background: var(--teal-soft); color: var(--teal); }
.btn-block { width: 100%; }
/* Microsoft sign-in (per MS brand guidelines: light variant) */
.btn-ms {
background: var(--surface);
color: var(--ink);
border: 1px solid var(--hair);
font-family: "Segoe UI", var(--font-sans);
font-weight: 600;
font-size: 15px;
padding: 13px 20px;
border-radius: 980px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
transition: transform 0.22s var(--ease-out), background 0.22s var(--ease), border-color 0.22s var(--ease);
text-decoration: none;
}
.btn-ms:hover { background: var(--surface-2); border-color: var(--muted); transform: scale(1.02); }
.btn-ms:active { transform: scale(0.98); }
.ms-logo {
display: inline-grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2px;
width: 18px; height: 18px;
}
.ms-logo > span { display: block; }
.ms-logo > span:nth-child(1) { background: #f25022; }
.ms-logo > span:nth-child(2) { background: #7fba00; }
.ms-logo > span:nth-child(3) { background: #00a4ef; }
.ms-logo > span:nth-child(4) { background: #ffb900; }
/* ============================================================
HEADER (app view)
============================================================ */
.top {
position: sticky;
top: 0;
z-index: 30;
background: var(--top-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--hair-soft);
}
.top-row {
max-width: 1180px;
margin: 0 auto;
padding: 18px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.top-right { display: flex; align-items: center; gap: 8px; }
.user-pill {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px 6px 6px;
background: var(--canvas-2);
border-radius: 980px;
font-size: 14px;
color: var(--ink-2);
font-weight: 500;
transition: background 0.22s var(--ease);
}
.user-pill:hover { background: var(--hair-soft); }
.user-avatar {
width: 26px; height: 26px;
border-radius: 50%;
background: linear-gradient(135deg, var(--teal), var(--teal-deep));
color: white;
display: grid; place-items: center;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.demo-badge {
display: none;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--muted);
padding: 4px 10px;
border-radius: 980px;
background: var(--canvas-2);
border: 1px solid var(--hair-soft);
}
.demo-badge.on { display: inline-flex; }
.demo-badge::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 0 3px rgba(63,163,163,0.18);
}
/* ============================================================
APP — HERO + SPLIT
============================================================ */
.app-hero {
max-width: 1180px;
margin: 0 auto;
padding: 64px 32px 24px;
text-align: center;
}
.app-eyebrow {
font-size: 14px;
font-weight: 600;
color: var(--teal);
letter-spacing: 0.01em;
margin-bottom: 14px;
opacity: 0;
animation: rise 0.7s var(--ease-out) 0.05s forwards;
}
.app-title {
font-size: 56px;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.05;
margin: 0 0 16px;
opacity: 0;
animation: rise 0.8s var(--ease-out) 0.12s forwards;
}
.app-sub {
font-size: 21px;
color: var(--muted);
max-width: 620px;
margin: 0 auto;
letter-spacing: -0.015em;
line-height: 1.4;
opacity: 0;
animation: rise 0.8s var(--ease-out) 0.2s forwards;
}
@keyframes rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.split {
max-width: 1180px;
margin: 0 auto;
padding: 40px 32px 96px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 32px;
align-items: start;
}
/* ============================================================
CARDS (glassmorphism, soft)
============================================================ */
.card {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border: 1px solid var(--hair-soft);
border-radius: 22px;
box-shadow:
0 1px 0 rgba(255,255,255,0.6) inset,
0 1px 2px rgba(0,0,0,0.02),
0 12px 40px -20px rgba(0,0,0,0.08);
opacity: 0;
animation: fade-in 0.7s var(--ease-out) 0.32s forwards;
position: relative;
overflow: hidden;
}
.card::before {
/* faint top sheen */
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 80px;
background: var(--card-sheen);
pointer-events: none;
}
.card-head { padding: 28px 28px 6px; position: relative; z-index: 1; }
.card-eyebrow {
font-size: 13px;
font-weight: 600;
color: var(--teal);
margin-bottom: 8px;
letter-spacing: -0.005em;
}
.card-title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0 0 6px;
}
.card-sub {
font-size: 15px;
color: var(--muted);
letter-spacing: -0.005em;
}
.card-body { padding: 22px 28px 28px; position: relative; z-index: 1; }
.compose { position: sticky; top: 96px; }
.compose.card { animation-delay: 0.32s; }
.linkbox.card { animation-delay: 0.42s; }
/* ============================================================
FORM
============================================================ */
.form-stack { display: flex; flex-direction: column; gap: 18px; }
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
background: var(--canvas-2);
border-radius: 980px;
padding: 4px;
gap: 0;
position: relative;
}
.seg button {
appearance: none;
border: 0;
background: transparent;
padding: 9px 14px;
border-radius: 980px;
color: var(--ink-2);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: color 0.22s var(--ease), background 0.22s var(--ease), box-shadow 0.22s var(--ease);
letter-spacing: -0.005em;
}
.seg button:hover { color: var(--ink); }
.seg button.on {
background: var(--surface);
color: var(--ink);
box-shadow: var(--shadow-seg);
}
.field { display: flex; flex-direction: column; gap: 8px; }
.label {
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
letter-spacing: -0.005em;
}
.label .opt { color: var(--muted); font-weight: 400; margin-left: 4px; }
.input, .textarea, .select {
appearance: none;
background: var(--surface);
border: 1px solid var(--hair);
border-radius: 12px;
color: var(--ink);
padding: 12px 14px;
font-size: 15px;
width: 100%;
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease), background 0.2s var(--ease);
font-family: var(--font-sans);
letter-spacing: -0.005em;
}
.input::placeholder, .textarea::placeholder { color: #a1a1a6; }
.input:focus, .textarea:focus, .select:focus {
outline: none;
border-color: var(--teal);
box-shadow: 0 0 0 4px rgba(63,163,163,0.10);
}
.textarea {
resize: vertical;
min-height: 132px;
line-height: 1.55;
font-size: 14.5px;
}
.select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236e6e73' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 36px;
cursor: pointer;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* file dropzone */
.drop {
border: 1.5px dashed var(--hair);
border-radius: 16px;
background: var(--surface);
padding: 28px 18px;
text-align: center;
cursor: pointer;
transition: border-color 0.22s var(--ease), background 0.22s var(--ease), transform 0.22s var(--ease-out);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.drop:hover, .drop.over { border-color: var(--teal); background: var(--teal-soft); }
.drop.filled {
border-style: solid;
text-align: left;
align-items: stretch;
border-color: var(--hair);
background: var(--surface);
}
.drop-ico {
width: 44px; height: 44px;
border-radius: 12px;
background: var(--teal-soft);
color: var(--teal);
display: grid;
place-items: center;
}
.drop-prim { font-size: 15px; font-weight: 500; color: var(--ink); letter-spacing: -0.01em; }
.drop-sec { font-size: 13px; color: var(--muted); }
.file-info { display: flex; align-items: center; gap: 14px; }
.file-info-text { flex: 1; min-width: 0; }
.file-name {
font-weight: 500;
font-size: 14.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
letter-spacing: -0.005em;
}
.file-size {
color: var(--muted);
font-size: 13px;
margin-top: 2px;
font-family: var(--font-mono);
}
/* ============================================================
RECEIPT (success after create)
============================================================ */
.receipt {
margin-top: 4px;
border-radius: 16px;
background: linear-gradient(180deg, var(--ok-soft), var(--surface));
border: 1px solid var(--ok-border);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
animation: rise 0.5s var(--ease-out) forwards;
}
.receipt-head {
display: flex;
align-items: center;
gap: 10px;
color: var(--ok);
font-weight: 600;
font-size: 14.5px;
letter-spacing: -0.005em;
}
.receipt-check {
width: 22px; height: 22px;
border-radius: 50%;
background: var(--ok);
color: white;
display: grid; place-items: center;
animation: pop 0.5s var(--ease-out) 0.1s both;
}
@keyframes pop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.receipt-url-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--ok-chip-border);
border-radius: 12px;
padding: 6px 6px 6px 14px;
}
.receipt-url {
flex: 1;
font-family: var(--font-mono);
font-size: 13px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ============================================================
LINK LIST (right column)
============================================================ */
.list-head {
padding: 24px 28px 14px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.list-stats { display: flex; gap: 18px; font-size: 13px; color: var(--muted); }
.list-stats b { color: var(--ink); font-weight: 600; }
.list-stats span { display: inline-flex; align-items: center; gap: 6px; }
.list-stats .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.list-stats .dot.ok { background: var(--ok); }
.list-stats .dot.off { background: var(--hair); }
.list-scroll {
max-height: calc(100vh - 320px);
overflow-y: auto;
padding: 0 16px 12px;
}
.list-scroll::-webkit-scrollbar { width: 10px; }
.list-scroll::-webkit-scrollbar-track { background: transparent; }
.list-scroll::-webkit-scrollbar-thumb { background: var(--hair); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 12px;
border-radius: 14px;
transition: background 0.22s var(--ease), transform 0.22s var(--ease-out);
cursor: default;
animation: row-in 0.5s var(--ease-out) both;
}
.row + .row { border-top: 1px solid var(--hair-soft); }
.row:hover { background: var(--row-hover); }
.row:hover + .row { border-top-color: transparent; }
@keyframes row-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.row.expired { opacity: 0.55; }
.row.expired:hover { opacity: 0.75; }
.row-ico {
width: 40px; height: 40px;
border-radius: 11px;
background: var(--canvas-2);
color: var(--ink-2);
display: grid;
place-items: center;
flex-shrink: 0;
}
.row-ico.text { background: var(--teal-soft); color: var(--teal); }
.row-ico.file { background: rgba(44,97,179,0.12); color: #5a9bff; }
.row-body { flex: 1; min-width: 0; }
.row-title {
font-size: 15px;
font-weight: 500;
color: var(--ink);
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.row-url {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.badges { display: flex; flex-wrap: wrap; gap: 6px; }
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
padding: 3px 8px;
border-radius: 980px;
color: var(--ink-2);
background: var(--canvas-2);
font-weight: 500;
letter-spacing: -0.005em;
}
.badge svg { width: 11px; height: 11px; }
.badge.pwd { color: var(--teal); background: var(--teal-soft); }
.badge.dl { color: var(--ink-2); }
.badge.expired {
color: var(--teal);
background: var(--teal-soft);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 10.5px;
}
.row-actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
.icon-btn-check { color: var(--ok) !important; }
/* empty state */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 64px 24px 56px;
}
.empty-ico {
width: 64px; height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, var(--teal-soft), var(--surface));
border: 1px solid var(--hair-soft);
display: grid; place-items: center;
color: var(--teal);
margin-bottom: 18px;
}
.empty-title {
font-size: 19px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.015em;
margin-bottom: 6px;
}
.empty-sub {
color: var(--muted);
font-size: 15px;
max-width: 300px;
letter-spacing: -0.005em;
}
/* ============================================================
LOGIN — hero
============================================================ */
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px;
position: relative;
background:
radial-gradient(ellipse 800px 500px at 50% -10%, var(--glow-1), transparent 60%),
radial-gradient(ellipse 800px 500px at 50% 110%, var(--glow-2), transparent 60%),
var(--canvas);
}
.login-top {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
justify-content: space-between;
padding: 22px 32px;
}
.login-top .login-help { font-size: 13px; color: var(--muted); }
.login-top .login-help a { color: var(--ink-2); text-decoration: none; }
.login-top .login-help a:hover { color: var(--teal); }
.login-content {
width: 100%;
max-width: 440px;
text-align: center;
}
.login-mark {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
opacity: 0;
animation: rise 0.7s var(--ease-out) 0.05s forwards;
}
.login-mark .logo-img { height: 64px; }
.login-top .logo-img { height: 56px; }
/* ============================================================
THEME TOGGLE — sliding pill with sun/moon
============================================================ */
.theme-toggle {
position: relative;
width: 60px;
height: 30px;
border-radius: 980px;
border: 1px solid var(--hair);
background: var(--surface-2);
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
transition: background-color 0.4s var(--ease), border-color 0.4s var(--ease);
-webkit-tap-highlight-color: transparent;
}
.theme-toggle:hover { border-color: var(--muted); }
.theme-toggle .tt-ico {
width: 14px;
height: 14px;
margin: 0 8px;
display: grid;
place-items: center;
color: var(--muted);
z-index: 1;
transition: color 0.4s var(--ease), opacity 0.4s var(--ease), transform 0.5s var(--ease-out);
pointer-events: none;
}
.theme-toggle .tt-ico svg { width: 100%; height: 100%; display: block; }
.theme-toggle .tt-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(180deg, #ffffff, #ececef);
box-shadow: 0 2px 6px rgba(0,0,0,0.18), 0 0 0 0.5px rgba(0,0,0,0.06);
transition: transform 0.45s cubic-bezier(0.5, 1.6, 0.4, 1), background 0.4s var(--ease);
}
[data-theme="dark"] .theme-toggle .tt-thumb {
transform: translateX(30px);
background: linear-gradient(180deg, #3a3a40, #232328);
box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 0.5px rgba(255,255,255,0.08), inset 0 0 0 1px rgba(255,255,255,0.04);
}
.theme-toggle .tt-ico.sun { color: #f5a623; }
[data-theme="dark"] .theme-toggle .tt-ico.sun { color: var(--muted); opacity: 0.55; }
.theme-toggle .tt-ico.moon { color: var(--muted); opacity: 0.55; }
[data-theme="dark"] .theme-toggle .tt-ico.moon { color: #c7c7e0; opacity: 1; }
.login-top-right {
display: inline-flex;
align-items: center;
gap: 12px;
}
.login-headline {
font-size: 56px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.02;
margin: 0 0 18px;
opacity: 0;
animation: rise 0.8s var(--ease-out) 0.15s forwards;
}
.login-headline .accent { color: var(--teal); }
.login-tagline {
font-size: 21px;
color: var(--muted);
letter-spacing: -0.015em;
line-height: 1.35;
margin: 0 0 40px;
opacity: 0;
animation: rise 0.8s var(--ease-out) 0.22s forwards;
}
.login-card {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(24px);
-webkit-backdrop-filter: saturate(180%) blur(24px);
border: 1px solid var(--hair-soft);
border-radius: 22px;
padding: 28px;
box-shadow:
0 1px 0 rgba(255,255,255,0.6) inset,
0 30px 80px -30px rgba(0,0,0,0.18),
0 8px 24px -8px rgba(0,0,0,0.06);
opacity: 0;
animation: fade-in 0.8s var(--ease-out) 0.32s forwards;
}
.login-form { display: flex; flex-direction: column; gap: 14px; }
.login-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0 4px;
color: var(--muted);
font-size: 12.5px;
letter-spacing: 0.02em;
}
.login-divider::before, .login-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--hair-soft);
}
.login-error {
display: flex;
align-items: flex-start;
gap: 9px;
background: var(--teal-soft);
border: 1px solid rgba(63,163,163,0.25);
color: var(--teal-deep);
padding: 10px 12px;
border-radius: 12px;
font-size: 13.5px;
animation: shake 0.3s ease;
letter-spacing: -0.005em;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.login-foot {
margin-top: 32px;
font-size: 12.5px;
color: var(--muted);
letter-spacing: -0.005em;
opacity: 0;
animation: fade-in 0.8s var(--ease-out) 0.42s forwards;
}
.login-foot a { color: var(--ink-2); text-decoration: none; margin: 0 6px; }
.login-foot a:hover { color: var(--teal); }
/* ============================================================
TOAST
============================================================ */
.toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: rgba(29,29,31,0.92);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
color: white;
padding: 12px 18px;
border-radius: 14px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 20px 40px -10px rgba(0,0,0,0.3);
z-index: 200;
opacity: 0;
animation: toast-in 0.35s var(--ease-out) forwards, toast-out 0.35s var(--ease) 2.1s forwards;
letter-spacing: -0.005em;
}
.toast .check {
width: 18px; height: 18px;
border-radius: 50%;
background: var(--ok);
display: grid; place-items: center;
flex-shrink: 0;
}
@keyframes toast-in {
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes toast-out {
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 900px) {
.split { grid-template-columns: 1fr; gap: 24px; padding: 28px 20px 64px; }
.compose { position: static; }
.top-row { padding: 14px 20px; }
.app-hero { padding: 40px 20px 16px; }
.app-title { font-size: 40px; }
.app-sub { font-size: 18px; }
.login-headline { font-size: 40px; }
.login-tagline { font-size: 18px; }
.user-pill .uname { display: none; }
}
@media (max-width: 520px) {
.grid-2 { grid-template-columns: 1fr; }
.card-head, .card-body { padding-left: 22px; padding-right: 22px; }
.login-top { padding: 16px 20px; }
.login-top .login-help { display: none; }
.app-title { font-size: 34px; }
.login-headline { font-size: 34px; }
}
[hidden] { display: none !important; }
</style>
</head>
<body>
<!-- ============================================================
LOGIN VIEW
============================================================ -->
<div id="login-view" class="login-wrap">
<div class="login-top">
<div class="logo" aria-label="Cereda Systems">
<img class="logo-img" src="/cereda-logo.png" alt="Cereda Systems" />
</div>
<div class="login-top-right">
<div class="login-help">
Need access? <a href="mailto:it@cereda-systems.de">it@cereda-systems.de</a>
</div>
<button class="theme-toggle" data-theme-toggle aria-label="Toggle dark mode" type="button">
<span class="tt-ico sun" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg></span>
<span class="tt-ico moon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></span>
<span class="tt-thumb" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="login-content">
<h1 class="login-headline">Securely share.<br/><span class="accent">Anything.</span></h1>
<p class="login-tagline">Encrypted, timelimited links for the Cereda Systems team.</p>
<div class="login-card">
<form id="login-form" class="login-form" autocomplete="off" novalidate>
<div id="login-error" class="login-error" hidden>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:1px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="login-error-text"></span>
</div>
<a class="btn-ms" href="/api/auth/microsoft?redirect=share-tool">
<span class="ms-logo" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
Sign in with Microsoft
</a>
<div class="login-divider">or with your account</div>
<div class="field">
<label class="label" for="login-username">Username</label>
<input id="login-username" class="input" type="text" placeholder="firstname.lastname" autocomplete="username" />
</div>
<div class="field">
<label class="label" for="login-password">Password</label>
<input id="login-password" class="input" type="password" placeholder="••••••••" autocomplete="current-password" />
</div>
<button type="submit" id="login-submit" class="btn btn-primary btn-block" style="padding:13px 20px;font-size:15px;margin-top:4px;">
Sign in
</button>
</form>
</div>
<div class="login-foot">
© Cereda Systems GmbH · Lüdenscheid
<span style="margin: 0 6px; color: var(--hair);">·</span>
<a href="#">Privacy</a><a href="#">Imprint</a>
</div>
</div>
</div>
<!-- ============================================================
APP VIEW
============================================================ -->
<div id="app-view" hidden>
<header class="top">
<div class="top-row">
<div class="logo" aria-label="Cereda Systems">
<img class="logo-img" src="/cereda-logo.png" alt="Cereda Systems" />
<span class="logo-divider"></span>
<span class="logo-sub">Secure Share</span>
</div>
<div class="top-right">
<span id="demo-badge" class="demo-badge">Demo</span>
<button class="theme-toggle" data-theme-toggle aria-label="Toggle dark mode" type="button">
<span class="tt-ico sun" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg></span>
<span class="tt-ico moon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></span>
<span class="tt-thumb" aria-hidden="true"></span>
</button>
<div class="user-pill">
<span class="user-avatar" id="user-initials">MS</span>
<span class="uname" id="user-name">m.schmidt</span>
</div>
<button class="btn btn-ghost" id="logout-btn" style="font-size:14px;padding:8px 14px;">Sign out</button>
</div>
</div>
</header>
<section class="app-hero">
<div class="app-eyebrow">Internal IT · Secure Share</div>
<h1 class="app-title">Send it. Set it. Forget it.</h1>
<p class="app-sub">Create encrypted, expiring links for any text or file. Revoke them anytime.</p>
</section>
<main class="split">
<!-- ============== COMPOSE ============== -->
<section class="card compose" aria-labelledby="compose-title">
<div class="card-head">
<div class="card-eyebrow">New share</div>
<h2 id="compose-title" class="card-title">Create a secure link</h2>
<div class="card-sub">Text or file. Optional password. Limited time, limited downloads.</div>
</div>
<div class="card-body">
<form id="create-form" class="form-stack" autocomplete="off" novalidate>
<div class="seg" role="tablist" aria-label="Share type">
<button type="button" data-type="text" class="on" role="tab" aria-selected="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>
Text
</button>
<button type="button" data-type="file" role="tab" aria-selected="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
File
</button>
</div>
<div class="field" id="text-panel">
<label class="label" for="content">Message <span class="opt">— text, snippet, key</span></label>
<textarea id="content" class="textarea" placeholder="Type or paste what you want to share…"></textarea>
</div>
<div class="field" id="file-panel" hidden>
<label class="label">Attachment</label>
<input id="file-input" type="file" hidden />
<div id="file-drop" class="drop" tabindex="0" role="button" aria-label="Choose a file">
<div class="drop-ico">
<svg viewBox="0 0 24 24" width="20" height="20" 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="drop-prim">Drop a file, or click to browse</div>
<div class="drop-sec">Up to 100 MB · any type</div>
</div>
</div>
<div class="field">
<label class="label" for="password">Password <span class="opt">— optional</span></label>
<input id="password" class="input" type="password" placeholder="Add a passphrase…" autocomplete="new-password" />
</div>
<div class="grid-2">
<div class="field">
<label class="label" for="expiry">Expires in</label>
<select id="expiry" class="select">
<option value="1">1 hour</option>
<option value="4">4 hours</option>
<option value="24" selected>24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="720">30 days</option>
<option value="0">Never</option>
</select>
</div>
<div class="field">
<label class="label" for="max-dl">Max downloads</label>
<select id="max-dl" class="select">
<option value="1">1 download</option>
<option value="5" selected>5 downloads</option>
<option value="10">10 downloads</option>
<option value="25">25 downloads</option>
<option value="0">Unlimited</option>
</select>
</div>
</div>
<button type="submit" id="create-btn" class="btn btn-primary btn-block" style="padding:14px 20px;font-size:15px;margin-top:4px;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5"/></svg>
Create secure link
</button>
<div id="receipt-slot"></div>
</form>
</div>
</section>
<!-- ============== LIST ============== -->
<section class="card linkbox" aria-labelledby="list-title">
<div class="list-head">
<div>
<div class="card-eyebrow">Your links</div>
<h2 id="list-title" class="card-title">Recent shares</h2>
</div>
<div class="list-stats" id="list-stats">
<span><span class="dot ok"></span><b id="stat-active">0</b>&nbsp;active</span>
<span><span class="dot off"></span><b id="stat-dead">0</b>&nbsp;closed</span>
</div>
</div>
<div class="list-scroll" id="list-scroll">
<div class="empty" id="empty-state">
<div class="empty-ico">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5"/></svg>
</div>
<div class="empty-title">No shares yet</div>
<div class="empty-sub">Create your first secure link with the form on the left.</div>
</div>
</div>
</section>
</main>
</div>
<!-- row template -->
<template id="row-tpl">
<div class="row" data-id="">
<div class="row-ico"></div>
<div class="row-body">
<div class="row-title"></div>
<div class="row-url"></div>
<div class="badges"></div>
</div>
<div class="row-actions">
<button class="btn-icon copy-btn" title="Copy link">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
<button class="btn-icon danger delete-btn" title="Delete">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div>
</div>
</template>
<script>
/* ============================================================
CEREDA SECURE SHARE — controller
============================================================ */
(() => {
"use strict";
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const loginView = $("#login-view");
const appView = $("#app-view");
const loginForm = $("#login-form");
const loginErr = $("#login-error");
const loginErrTx = $("#login-error-text");
const userName = $("#user-name");
const userInit = $("#user-initials");
const logoutBtn = $("#logout-btn");
const demoBadge = $("#demo-badge");
const createForm = $("#create-form");
const createBtn = $("#create-btn");
const textPanel = $("#text-panel");
const filePanel = $("#file-panel");
const fileInput = $("#file-input");
const fileDrop = $("#file-drop");
const receiptSlot= $("#receipt-slot");
const listScroll = $("#list-scroll");
const emptyState = $("#empty-state");
const rowTpl = $("#row-tpl");
let currentType = "text";
let pickedFile = null;
let shares = [];
let demoMode = false;
/* ---------- Auth (httpOnly-Cookie, kein Token in localStorage/URL) ---------- */
let demoModeFlag = false;
/* ---------- Fetch helper ---------- */
async function api(path, opts = {}) {
const headers = new Headers(opts.headers || {});
if (opts.body && !(opts.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(path, { ...opts, headers, credentials: "include" });
if (!res.ok) {
const err = new Error("HTTP " + res.status);
err.status = res.status;
try { err.body = await res.json(); } catch {}
throw err;
}
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
/* ---------- Theme toggle ---------- */
(function initTheme() {
const KEY = "cereda-theme";
const saved = (() => { try { return localStorage.getItem(KEY); } catch { return null; } })();
const initial = saved || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", initial);
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-theme-toggle]");
if (!btn) return;
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
const next = cur === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem(KEY, next); } catch {}
});
})();
/* ---------- Init ---------- */
async function init() {
try {
const me = await api("/api/auth/me");
showApp(me);
await loadShares();
} catch (e) {
if (e.status === 401 || e.status === 403) {
showLogin();
} else {
enterDemoMode();
}
}
}
/* ---------- Demo fallback ---------- */
function enterDemoMode() {
demoMode = true;
demoModeFlag = true;
showApp({ username: "m.schmidt", display_name: "Marco Schmidt" });
demoBadge.classList.add("on");
shares = seedShares();
renderShares();
}
function seedShares() {
const now = Date.now();
const hour = 3_600_000;
return [
{ id:"k7d2x4n9", token:"k7d2x4n9pqrabc", type:"file",
filename:"Netzwerkdiagramm_v3.pdf",
public_url: location.origin + "/s/k7d2x4n9pqrabc",
size:4_280_000, password_hash:"$2b$10$...",
expires_at: now + 2*24*hour,
max_downloads:10, download_count:3, created_at: now - 3*hour },
{ id:"a3b8m1qz", token:"a3b8m1qzkwxvyz", type:"text",
filename:"VPN credentials · Zurich site",
public_url: location.origin + "/s/a3b8m1qzkwxvyz",
size:184, password_hash:"$2b$10$...",
expires_at: now + 1*hour,
max_downloads:1, download_count:0, created_at: now - 25*60_000 },
{ id:"p9w2t6sx", token:"p9w2t6sxrbnfgh", type:"file",
filename:"Backup_Logs_May2026.zip",
public_url: location.origin + "/s/p9w2t6sxrbnfgh",
size:28_400_000, password_hash:null,
expires_at: now + 6*24*hour,
max_downloads:0, download_count:12, created_at: now - 18*hour },
{ id:"g4h7r1jv", token:"g4h7r1jvyumzab", type:"text",
filename:"Onboarding checklist · new hires",
public_url: location.origin + "/s/g4h7r1jvyumzab",
size:1240, password_hash:null,
expires_at: null,
max_downloads:0, download_count:47, created_at: now - 5*24*hour },
{ id:"y2n8c5fb", token:"y2n8c5fbgxqecd", type:"file",
filename:"Server_Audit_Q1.xlsx",
public_url: location.origin + "/s/y2n8c5fbgxqecd",
size:612_000, password_hash:"$2b$10$...",
expires_at: now - 2*hour,
max_downloads:5, download_count:5, created_at: now - 8*24*hour },
];
}
function showLogin() {
appView.hidden = true;
loginView.hidden = false;
document.body.style.background = "";
}
function showApp(me) {
loginView.hidden = true;
appView.hidden = false;
document.body.style.background = "var(--canvas)";
if (me) {
const name = me.display_name || me.username || "Operator";
userName.textContent = me.username || name.toLowerCase().replace(/\s+/g, ".");
userInit.textContent = name.split(/\s+/).map(s => s[0]).join("").slice(0, 2).toUpperCase();
}
}
/* ---------- Login ---------- */
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
loginErr.hidden = true;
const username = $("#login-username").value.trim();
const password = $("#login-password").value;
if (!username || !password) {
loginErrTx.textContent = "Enter both username and password to continue.";
loginErr.hidden = false;
return;
}
const btn = $("#login-submit");
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = "Signing in…";
try {
const res = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password })
});
if (res && res.status === "success") {
const me = await api("/api/auth/me").catch(() => null);
showApp(me || { username });
await loadShares();
} else throw new Error("Login fehlgeschlagen.");
} catch (err) {
if (err.status === 401 || err.status === 403) {
// Real auth rejection — show the error
loginErrTx.textContent = "Invalid credentials. Please try again.";
loginErr.hidden = false;
btn.disabled = false;
btn.innerHTML = original;
} else {
// No backend or unexpected status — drop into demo so the design is viewable
setTimeout(enterDemoMode, 200);
}
}
});
/* ---------- Logout ---------- */
logoutBtn.addEventListener("click", () => {
if (!demoModeFlag) { api("/api/auth/logout", { method: "POST" }).catch(() => {}); }
shares = [];
pickedFile = null;
demoMode = false;
demoBadge.classList.remove("on");
renderShares();
showLogin();
$("#login-username").value = "";
$("#login-password").value = "";
});
/* ---------- Type toggle ---------- */
$$(".seg button[data-type]").forEach((b) => {
b.addEventListener("click", () => {
const t = b.dataset.type;
currentType = t;
$$(".seg button[data-type]").forEach((x) => {
const on = x.dataset.type === t;
x.classList.toggle("on", on);
x.setAttribute("aria-selected", on ? "true" : "false");
});
textPanel.hidden = (t !== "text");
filePanel.hidden = (t !== "file");
});
});
/* ---------- File picker / drag-and-drop ---------- */
fileDrop.addEventListener("click", () => fileInput.click());
fileDrop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener("change", (e) => {
pickedFile = e.target.files?.[0] || null;
renderFileDrop();
});
["dragenter","dragover"].forEach((ev) => fileDrop.addEventListener(ev, (e) => {
e.preventDefault(); fileDrop.classList.add("over");
}));
["dragleave","drop"].forEach((ev) => fileDrop.addEventListener(ev, (e) => {
e.preventDefault(); fileDrop.classList.remove("over");
}));
fileDrop.addEventListener("drop", (e) => {
const f = e.dataTransfer?.files?.[0];
if (f) { pickedFile = f; renderFileDrop(); }
});
function renderFileDrop() {
if (!pickedFile) {
fileDrop.classList.remove("filled");
fileDrop.innerHTML = `
<div class="drop-ico">
<svg viewBox="0 0 24 24" width="20" height="20" 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="drop-prim">Drop a file, or click to browse</div>
<div class="drop-sec">Up to 100 MB · any type</div>`;
return;
}
fileDrop.classList.add("filled");
fileDrop.innerHTML = `
<div class="file-info">
<div class="drop-ico">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="file-info-text">
<div class="file-name"></div>
<div class="file-size"></div>
</div>
<button type="button" class="btn-icon" data-clear title="Remove">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>`;
fileDrop.querySelector(".file-name").textContent = pickedFile.name;
fileDrop.querySelector(".file-size").textContent = formatBytes(pickedFile.size);
fileDrop.querySelector("[data-clear]").addEventListener("click", (e) => {
e.stopPropagation();
pickedFile = null;
fileInput.value = "";
renderFileDrop();
});
}
/* ---------- Create share ---------- */
createForm.addEventListener("submit", async (e) => {
e.preventDefault();
const content = $("#content").value;
const password = $("#password").value;
const expires_in_hours = $("#expiry").value;
const max_downloads = $("#max-dl").value;
if (currentType === "text" && !content.trim()) { flash("Add some text to share.", "err"); return; }
if (currentType === "file" && !pickedFile) { flash("Pick a file to share.", "err"); return; }
const fd = new FormData();
fd.append("type", currentType);
if (currentType === "text") fd.append("text_content", content);
else fd.append("file", pickedFile);
if (password) fd.append("password", password);
fd.append("expires_in_hours", expires_in_hours);
fd.append("max_downloads", max_downloads);
createBtn.disabled = true;
const original = createBtn.innerHTML;
createBtn.innerHTML = "Creating…";
try {
let created;
if (demoMode) created = mockCreate(fd);
else created = await api("/api/shares", { method: "POST", body: fd });
shares.unshift(created);
renderShares();
showReceipt(created);
$("#content").value = "";
$("#password").value = "";
pickedFile = null;
renderFileDrop();
} catch (err) {
flash("Could not create share (" + (err.status || "network") + ").", "err");
} finally {
createBtn.disabled = false;
createBtn.innerHTML = original;
}
});
function mockCreate(fd) {
const now = Date.now();
const hours = parseInt(fd.get("expires_in_hours"), 10) || 0;
const type = fd.get("type");
const file = fd.get("file");
const text = fd.get("text_content") || "";
const name = type === "file"
? (file?.name || "untitled")
: (text.split("\n")[0].slice(0, 48) || "Text snippet");
const id = Math.random().toString(36).slice(2, 10);
const token = id + Math.random().toString(36).slice(2, 8);
return {
id, token, type,
filename: name,
public_url: location.origin + "/s/" + token,
size: type === "file" ? (file?.size || 0) : text.length,
password_hash: fd.get("password") ? "$2b$10$..." : null,
expires_at: hours === 0 ? null : now + hours * 3_600_000,
max_downloads: parseInt(fd.get("max_downloads"), 10) || 0,
download_count: 0,
created_at: now,
};
}
/* ---------- Receipt ---------- */
function showReceipt(s) {
const url = s.public_url || (location.origin + "/s/" + (s.token || s.id));
receiptSlot.innerHTML = "";
const box = document.createElement("div");
box.className = "receipt";
box.setAttribute("role", "status");
box.setAttribute("aria-live", "polite");
box.innerHTML = `
<div class="receipt-head">
<span class="receipt-check">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
Your link is ready
</div>
<div class="receipt-url-row">
<span class="receipt-url"></span>
<button class="btn-icon" data-copy title="Copy link">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>`;
box.querySelector(".receipt-url").textContent = url;
const btn = box.querySelector("[data-copy]");
btn.addEventListener("click", () => copyWithAnim(btn, url));
receiptSlot.appendChild(box);
}
/* ---------- Load + render ---------- */
async function loadShares() {
try {
const list = await api("/api/shares");
shares = Array.isArray(list) ? list : (list?.shares || []);
} catch (e) {
if (e.status === 401) { showLogin(); return; }
// 403 = not admin, just show empty list — user can still create shares
shares = [];
}
renderShares();
}
function renderShares() {
$$(".row", listScroll).forEach((n) => n.remove());
const now = Date.now();
const sorted = [...shares].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const isClosed = (s) =>
(s.expires_at && s.expires_at < now) ||
(s.max_downloads && s.max_downloads > 0 && s.download_count >= s.max_downloads);
const live = sorted.filter((s) => !isClosed(s)).length;
$("#stat-active").textContent = live;
$("#stat-dead").textContent = sorted.length - live;
if (!sorted.length) { emptyState.hidden = false; return; }
emptyState.hidden = true;
sorted.forEach((s, i) => {
const node = rowTpl.content.firstElementChild.cloneNode(true);
node.dataset.id = s.id;
node.style.animationDelay = (i * 30) + "ms";
const closed = isClosed(s);
if (closed) node.classList.add("expired");
const ico = node.querySelector(".row-ico");
ico.classList.add(s.type === "file" ? "file" : "text");
ico.innerHTML = s.type === "file"
? `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`
: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>`;
node.querySelector(".row-title").textContent = s.filename || "Untitled";
const url = s.public_url || (location.origin + "/s/" + (s.token || s.id));
node.querySelector(".row-url").textContent = url.replace(/^https?:\/\//, "");
const badges = node.querySelector(".badges");
badges.innerHTML = "";
if (closed) {
badges.appendChild(badge("expired", null,
(s.max_downloads && s.download_count >= s.max_downloads) ? "Cap reached" : "Expired"));
} else {
badges.appendChild(badge("", iconClock(), expiryLabel(s.expires_at, now)));
}
if (s.password_hash) badges.appendChild(badge("pwd", iconLock(), "Password"));
badges.appendChild(badge("dl", iconDl(), dlLabel(s)));
if (s.type === "file" && s.size != null) badges.appendChild(badge("", null, formatBytes(s.size)));
const copyBtn = node.querySelector(".copy-btn");
copyBtn.addEventListener("click", () => copyWithAnim(copyBtn, url));
node.querySelector(".delete-btn").addEventListener("click", () => deleteShare(s));
listScroll.appendChild(node);
});
}
function badge(cls, iconHtml, text) {
const b = document.createElement("span");
b.className = "badge" + (cls ? " " + cls : "");
if (iconHtml) b.innerHTML = iconHtml;
const t = document.createElement("span");
t.textContent = text;
b.appendChild(t);
return b;
}
const iconClock = () => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>`;
const iconLock = () => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
const iconDl = () => `<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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
/* ---------- Delete ---------- */
async function deleteShare(s) {
if (!confirm(`Delete share "${s.filename}"? This cannot be undone.`)) return;
try {
if (!demoMode) await api("/api/shares/" + encodeURIComponent(s.id), { method: "DELETE" });
shares = shares.filter((x) => x.id !== s.id);
renderShares();
flash("Share deleted.", "ok");
} catch (err) {
flash("Delete failed (" + (err.status || "network") + ").", "err");
}
}
/* ---------- Utilities ---------- */
function formatBytes(b) {
if (b == null) return "";
if (b < 1024) return b + " B";
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB";
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + " MB";
return (b / 1024 / 1024 / 1024).toFixed(2) + " GB";
}
function expiryLabel(ts, now) {
if (!ts) return "No expiry";
const diff = ts - now;
if (diff <= 0) return "Expired";
const m = Math.floor(diff / 60_000);
if (m < 60) return m + " min left";
const h = Math.floor(m / 60);
if (h < 24) return h + "h left";
return Math.floor(h / 24) + "d left";
}
function dlLabel(s) {
if (s.max_downloads && s.max_downloads > 0) return s.download_count + " / " + s.max_downloads;
return s.download_count + " · ∞";
}
/* ---------- Copy with smooth check animation ---------- */
async function copyWithAnim(btn, text) {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch {}
document.body.removeChild(ta);
}
if (btn) {
const original = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
btn.classList.add("icon-btn-check");
setTimeout(() => { btn.innerHTML = original; btn.classList.remove("icon-btn-check"); }, 1400);
}
flash("Link copied", "ok");
}
/* ---------- Toast ---------- */
let toastTimer = null;
function flash(msg, kind = "ok") {
const existing = $(".toast");
if (existing) existing.remove();
if (toastTimer) clearTimeout(toastTimer);
const el = document.createElement("div");
el.className = "toast";
if (kind === "ok") {
el.innerHTML = `
<span class="check"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
<span></span>`;
} else {
el.innerHTML = `<span></span>`;
}
el.lastElementChild.textContent = msg;
document.body.appendChild(el);
toastTimer = setTimeout(() => el.remove(), 2600);
}
// Periodic re-render so expiry labels stay fresh
setInterval(() => { if (shares.length && !appView.hidden) renderShares(); }, 30_000);
init();
})();
</script>
</body>
</html>