Files
IT-Nexus/frontend/public/share-tool.html

1728 lines
59 KiB
HTML
Raw Permalink Normal View History

2026-06-01 20:49:07 +02:00
<!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;
2026-06-01 20:49:07 +02:00
/* ---------- 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" });
2026-06-01 20:49:07 +02:00
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;
2026-06-01 20:49:07 +02:00
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") {
2026-06-01 20:49:07 +02:00
const me = await api("/api/auth/me").catch(() => null);
showApp(me || { username });
await loadShares();
} else throw new Error("Login fehlgeschlagen.");
2026-06-01 20:49:07 +02:00
} 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(() => {}); }
2026-06-01 20:49:07 +02:00
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; }
2026-06-01 20:49:07 +02:00
// 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>