- 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>
1728 lines
59 KiB
HTML
1728 lines
59 KiB
HTML
<!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, time‑limited 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> active</span>
|
||
<span><span class="dot off"></span><b id="stat-dead">0</b> 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>
|