Redesigned status, log, tag generator and settings pages: spool rings in filament colour, live job card (progress and time left read from CC2 Control's /api/printer while a job runs), log page with filters, iOS-style settings with a write/dry-run switch. On phones: bottom tab bar, printer switcher, slot details in a bottom sheet. Dark mode follows the system. Web app manifest and home screen icons for "Add to Home Screen". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
284 lines
18 KiB
HTML
284 lines
18 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>{% block title %}CC2 Bridge{% endblock %}</title>
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-title" content="CC2 Bridge">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||
<meta name="theme-color" content="#EDEFF2" media="(prefers-color-scheme: light)">
|
||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)">
|
||
<meta name="format-detection" content="telephone=no">
|
||
<link rel="manifest" href="/manifest.webmanifest">
|
||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||
<link rel="apple-touch-icon" href="/static/icon-180.png">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #EDEFF2; --surface: #FFFFFF; --surface2: #F6F7F9; --ink: #14181F; --muted: #5B6472; --faint: #9AA3B0;
|
||
--line: #DCE0E6; --line2: #EDEFF2; --track: #E4E7EC;
|
||
--accent: #2F5BEA; --accent-ink: #FFFFFF; --accent-bg: #E6ECFD;
|
||
--ok: #17803D; --ok-bg: #E3F4E9; --warn: #9A5B00; --warn-bg: #FFF1DC; --err: #B42318; --err-bg: #FDE7E5; --err-line: #F3C6C1;
|
||
--hero: #14181F; --hero-ink: #FFFFFF; --hero-muted: #A9B3C2; --hero-track: #2B323D; --hero-accent: #6E8BFF; --hero-line: #3A4250;
|
||
--tabbar: rgba(255, 255, 255, .92);
|
||
--radius: 16px; --radius-sm: 10px;
|
||
--font: Manrope, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
color-scheme: light;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #0E1116; --surface: #171B22; --surface2: #1E232C; --ink: #E8ECF2; --muted: #9AA4B2; --faint: #6B7482;
|
||
--line: #2A303A; --line2: #222731; --track: #2A303A;
|
||
--accent: #7C97FF; --accent-ink: #0B1230; --accent-bg: #1D2747;
|
||
--ok: #5CC98A; --ok-bg: #13291C; --warn: #F0B55C; --warn-bg: #33270F; --err: #FF8A80; --err-bg: #3A1A18; --err-line: #5A2622;
|
||
--hero: #1F2531; --hero-track: #333B48; --hero-line: #3F4857;
|
||
--tabbar: rgba(23, 27, 34, .92);
|
||
color-scheme: dark;
|
||
}
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased;
|
||
-webkit-tap-highlight-color: transparent; }
|
||
a { color: var(--accent); }
|
||
|
||
/* top bar (wide screens) */
|
||
.top { background: var(--surface); border-bottom: 1px solid var(--line); padding-top: env(safe-area-inset-top); }
|
||
.top .in { max-width: 1240px; margin: 0 auto; padding: 0 24px; min-height: 64px; display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
|
||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 17px; letter-spacing: -.01em; color: var(--ink); text-decoration: none; }
|
||
.brand img { width: 30px; height: 30px; border-radius: 8px; }
|
||
.top nav { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.top nav a { text-decoration: none; color: var(--muted); padding: 8px 14px; border-radius: var(--radius-sm); font-weight: 600; }
|
||
.top nav a:hover { color: var(--ink); }
|
||
.top nav a.on { color: var(--ink); background: var(--bg); font-weight: 700; }
|
||
.top .right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||
.poll { font-size: 13px; color: var(--muted); }
|
||
|
||
/* mode pill */
|
||
.mode { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; padding: 6px 12px; border-radius: 99px;
|
||
text-decoration: none; white-space: nowrap; }
|
||
.mode::before { content: ""; width: 8px; height: 8px; border-radius: 99px; background: currentColor; }
|
||
.mode.dry { background: var(--accent-bg); color: var(--accent); }
|
||
.mode.live { background: var(--err-bg); color: var(--err); }
|
||
.mode:empty { display: none; }
|
||
|
||
main { max-width: 1240px; margin: 0 auto; padding: 28px 24px 56px; }
|
||
.pagehead { display: none; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||
.pagehead h1 { margin: 0; font-size: 32px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; }
|
||
h2 { font-size: 20px; font-weight: 800; letter-spacing: -.01em; margin: 0; }
|
||
h3 { font-size: 15px; font-weight: 800; margin: 0; }
|
||
|
||
/* tab bar (phones) */
|
||
.tabbar { display: none; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; background: var(--tabbar);
|
||
-webkit-backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px);
|
||
border-top: 1px solid var(--line); padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||
grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||
.tabbar a { text-decoration: none; color: var(--muted); display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
gap: 3px; min-height: 48px; font-size: 11px; font-weight: 700; }
|
||
.tabbar a.on { color: var(--accent); }
|
||
.tabbar svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||
|
||
@media (max-width: 759px) {
|
||
.top { display: none; }
|
||
.tabbar { display: grid; }
|
||
main { padding: calc(14px + env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) calc(96px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||
.pagehead { display: flex; }
|
||
.desktop-only { display: none !important; }
|
||
}
|
||
@media (min-width: 760px) { .phone-only { display: none !important; } }
|
||
|
||
/* building blocks */
|
||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
|
||
@media (max-width: 759px) { .card { border: 0; } }
|
||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||
.muted { color: var(--muted); }
|
||
.small { font-size: 13px; }
|
||
.mono { font-family: var(--mono); }
|
||
.label { font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||
.section-label { margin: 22px 4px 8px; }
|
||
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 3px 9px; border-radius: 99px;
|
||
background: var(--bg); color: var(--muted); white-space: nowrap; }
|
||
.chip.dot::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: currentColor; }
|
||
.chip.ok { background: var(--ok-bg); color: var(--ok); }
|
||
.chip.warn { background: var(--warn-bg); color: var(--warn); }
|
||
.chip.err { background: var(--err-bg); color: var(--err); }
|
||
.chip.accent { background: var(--accent-bg); color: var(--accent); }
|
||
.banner { border-radius: 14px; padding: 12px 14px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 14px; font-weight: 600; }
|
||
.banner.err { background: var(--err-bg); color: var(--err); }
|
||
.banner.warn { background: var(--warn-bg); color: var(--warn); }
|
||
.banner.ok { background: var(--ok-bg); color: var(--ok); }
|
||
.banner.dry { background: var(--accent-bg); color: var(--accent); }
|
||
.banner a { color: inherit; }
|
||
|
||
button, .btn { font: inherit; font-size: 14px; font-weight: 700; min-height: 40px; padding: 0 14px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center;
|
||
justify-content: center; gap: 8px; text-decoration: none; }
|
||
button:hover, .btn:hover { border-color: var(--faint); }
|
||
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||
button.danger { color: var(--err); border-color: var(--err-line); }
|
||
button.plain { border: 0; background: transparent; color: var(--accent); padding: 0; min-height: 44px; }
|
||
button:disabled { opacity: .5; cursor: default; }
|
||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible {
|
||
outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
|
||
input, select, textarea { font: inherit; font-size: 16px; color: var(--ink); background: var(--surface2); border: 1px solid var(--line);
|
||
border-radius: var(--radius-sm); padding: 0 12px; min-height: 44px; width: 100%; }
|
||
textarea { padding: 10px 12px; font-family: var(--mono); font-size: 13px; }
|
||
input[type=color] { padding: 4px; width: 52px; flex: 0 0 52px; }
|
||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||
.field > label, .flabel { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||
.fields { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||
|
||
/* iOS-style switch */
|
||
.switch { position: relative; display: inline-flex; flex: 0 0 auto; }
|
||
.switch input { position: absolute; inset: 0; opacity: 0; margin: 0; min-height: 0; cursor: pointer; }
|
||
.switch span { width: 52px; height: 32px; border-radius: 99px; background: var(--track); transition: background .2s; position: relative; }
|
||
.switch span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; border-radius: 99px; background: #fff;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, .25); transition: transform .2s; }
|
||
.switch input:checked + span { background: var(--ok); }
|
||
.switch.danger input:checked + span { background: var(--err); }
|
||
.switch input:checked + span::after { transform: translateX(20px); }
|
||
.switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
|
||
/* grouped lists */
|
||
.group { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||
@media (max-width: 759px) { .group { border: 0; } }
|
||
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 56px; border-top: 1px solid var(--line2); }
|
||
.item:first-child { border-top: 0; }
|
||
.item .grow { flex: 1; min-width: 0; }
|
||
.item .title { font-weight: 800; }
|
||
.item .sub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.chev { width: 8px; height: 14px; flex: 0 0 8px; fill: none; stroke: var(--faint); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||
button.item { width: 100%; text-align: left; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: transparent;
|
||
font-weight: inherit; font-size: inherit; color: inherit; justify-content: flex-start; }
|
||
button.item:first-child { border-top: 0; }
|
||
button.item:hover { background: var(--surface2); }
|
||
|
||
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 99px; border: 1px solid var(--line); vertical-align: -2px; }
|
||
.ring { border-radius: 99px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; box-shadow: inset 0 0 0 1px var(--line); }
|
||
.ring > div { border-radius: 99px; background: var(--surface); display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||
.icon-badge { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
|
||
font-weight: 800; font-size: 14px; background: var(--bg); color: var(--ink); }
|
||
.icon-badge svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
|
||
|
||
code { font-family: var(--mono); font-size: 13px; background: var(--surface2); padding: 1px 6px; border-radius: 6px; overflow-wrap: anywhere; }
|
||
pre { font-family: var(--mono); font-size: 12px; background: var(--surface2); padding: 12px; border-radius: var(--radius-sm); overflow-x: auto;
|
||
white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line2); vertical-align: top; }
|
||
th { font-size: 12px; color: var(--muted); font-weight: 700; }
|
||
.table-wrap { overflow-x: auto; }
|
||
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 14px; }
|
||
dl.kv dt { color: var(--muted); }
|
||
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||
|
||
dialog.sheet { border: 0; padding: 0; background: var(--surface); color: var(--ink); border-radius: 20px; width: min(480px, calc(100% - 32px));
|
||
max-height: calc(100% - 48px); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
|
||
dialog.sheet::backdrop { background: rgba(10, 12, 16, .45); }
|
||
dialog.sheet .sheet-in { padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 16px; }
|
||
@media (max-width: 759px) {
|
||
dialog.sheet { width: 100%; max-width: 100%; margin: auto 0 0; border-radius: 22px 22px 0 0; max-height: 88vh; }
|
||
dialog.sheet .sheet-in { padding-bottom: calc(20px + env(safe-area-inset-bottom)); }
|
||
dialog.sheet .grip { display: block; }
|
||
}
|
||
.grip { display: none; width: 40px; height: 5px; border-radius: 99px; background: var(--line); margin: 0 auto -6px; }
|
||
|
||
.toast { position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); transform: translateX(-50%); background: var(--ink);
|
||
color: var(--bg); padding: 10px 16px; border-radius: 12px; font-weight: 700; font-size: 14px; opacity: 0; transition: opacity .2s;
|
||
pointer-events: none; z-index: 50; max-width: calc(100% - 32px); }
|
||
@media (max-width: 759px) { .toast { bottom: calc(88px + env(safe-area-inset-bottom)); } }
|
||
.toast.show { opacity: 1; }
|
||
</style>
|
||
{% block head %}{% endblock %}
|
||
</head>
|
||
<body>
|
||
{% set tabs = [
|
||
('/', 'status.html', 'Status', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8'),
|
||
('/log', 'log.html', 'Protokoll', 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01'),
|
||
('/tags', 'tags.html', 'Tags', 'M6 8.5a8 8 0 0 1 0 7M10 6a12 12 0 0 1 0 12M14 3.5a16 16 0 0 1 0 17'),
|
||
('/settings', 'settings.html', 'Einstellungen', 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'),
|
||
] %}
|
||
<header class="top"><div class="in">
|
||
<a class="brand" href="/"><img src="/static/icon.svg" alt="">CC2 Bridge</a>
|
||
<nav aria-label="Hauptnavigation">
|
||
{% for href, name, label, icon in tabs %}<a href="{{ href }}" class="{{ 'on' if active == name }}" {% if active == name %}aria-current="page"{% endif %}>{{ 'Tag-Generator' if name == 'tags.html' else label }}</a>{% endfor %}
|
||
</nav>
|
||
<div class="right"><span class="poll" id="lastpoll"></span><a href="/settings" class="mode" id="mode"></a></div>
|
||
</div></header>
|
||
<main>
|
||
<div class="pagehead"><h1>{% block heading %}CC2 Bridge{% endblock %}</h1><a href="/settings" class="mode" id="mode2"></a></div>
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
<nav class="tabbar" aria-label="Hauptnavigation">
|
||
{% for href, name, label, icon in tabs %}<a href="{{ href }}" class="{{ 'on' if active == name }}" {% if active == name %}aria-current="page"{% endif %}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="{{ icon }}"/></svg>{{ label }}</a>{% endfor %}
|
||
</nav>
|
||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
async function api(method, url, body) {
|
||
const opt = { method, headers: { 'X-Bridge-Request': '1' } };
|
||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||
const r = await fetch(url, opt);
|
||
let data = null;
|
||
try { data = await r.json(); } catch (e) {}
|
||
if (!r.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + r.status));
|
||
return data;
|
||
}
|
||
function toast(msg) {
|
||
const t = $('toast'); t.textContent = msg; t.classList.add('show');
|
||
clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('show'), 2200);
|
||
}
|
||
async function copyText(text) {
|
||
try { await navigator.clipboard.writeText(text); }
|
||
catch (e) {
|
||
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta);
|
||
ta.select(); document.execCommand('copy'); ta.remove();
|
||
}
|
||
toast('Kopiert');
|
||
}
|
||
function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; }
|
||
function fmtClock(t, sec) {
|
||
return t ? new Date(t * 1000).toLocaleTimeString('de-DE', sec ? {} : { hour: '2-digit', minute: '2-digit' }) : '–';
|
||
}
|
||
function fmtNum(v, d = 0) { return Number(v).toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d }); }
|
||
function setMode(dry) {
|
||
for (const id of ['mode', 'mode2']) {
|
||
const m = $(id);
|
||
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
|
||
m.className = 'mode ' + (dry ? 'dry' : 'live');
|
||
}
|
||
}
|
||
function setPoll(t) { $('lastpoll').textContent = t ? 'Abgefragt ' + fmtClock(t, true) : ''; }
|
||
function logIcon(e) {
|
||
if (e.level === 'error') return ['!', 'var(--err-bg)', 'var(--err)'];
|
||
if (e.level === 'warn') return ['!', 'var(--warn-bg)', 'var(--warn)'];
|
||
if (e.level === 'dry') return ['D', 'var(--accent-bg)', 'var(--accent)'];
|
||
if (/gebucht/.test(e.msg)) return ['g', 'var(--ok-bg)', 'var(--ok)'];
|
||
if (/fragt nach/.test(e.msg)) return ['↻', 'var(--warn-bg)', 'var(--warn)'];
|
||
if (/zugeordnet/.test(e.msg)) return ['+', 'var(--accent-bg)', 'var(--accent)'];
|
||
return ['✓', 'var(--bg)', 'var(--ink)'];
|
||
}
|
||
function splitPrinter(msg, names) {
|
||
// "erledigt: CC2 Links: in CC2 Control …" -> ["CC2 Links", "In CC2 Control …"]; the check mark already says "erledigt".
|
||
let pre = '';
|
||
if (msg.startsWith('erledigt: ')) msg = msg.slice(10);
|
||
else if (msg.startsWith('würde ')) { pre = 'würde '; msg = msg.slice(6); }
|
||
let printer = 'Bridge';
|
||
for (const n of names) if (msg.startsWith(n + ': ')) { printer = n; msg = msg.slice(n.length + 2); break; }
|
||
msg = pre + msg;
|
||
return [printer, msg.charAt(0).toUpperCase() + msg.slice(1)];
|
||
}
|
||
const CHEV = '<svg class="chev" viewBox="0 0 8 14" aria-hidden="true"><path d="M1 1l6 6-6 6"/></svg>';
|
||
api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {});
|
||
</script>
|
||
{% block script %}{% endblock %}
|
||
</body>
|
||
</html>
|