Files
cc2-spoolman-bridge/app/templates/base.html
T
Jonas MenzelandClaude Opus 5.5 64ea91d107
CI / test (push) Successful in 2m29s
CI / image (push) Successful in 39s
Spool page for NFC links, bridge address; vendor-first new spool form
- /s/<id> shows a spool: rest in Spoolman and CC2 Control, where it is,
  last booking, history, links to Spoolman and the tag generator.
- Setting "Adresse der Bridge"; the tag generator can put <address>/s/<id>
  on the tag, so an iPhone held to the roll opens that page.
- New spool: vendor and material chosen from Spoolman, the vendor's
  filaments offered, values taken from similar filaments, duplicates
  pointed out and never created twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 21:52:23 +01:00

285 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="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; }
[hidden] { display: none !important; }
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>