feat: Tab „Führungen" für ausgefallene und verschobene Sonderführungen — Version 1.14.0
Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für jeden angemeldeten BEO. Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar. Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs, Design in scripts/anleitung.template.html), automatisch vor jedem Build. public/anleitung.html ist deshalb nicht mehr in git. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bedienungsanleitung – Führungsbuch Sternwarte Welzheim</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
color: #1a1a2e;
|
||||
background: #f4f6fb;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
|
||||
color: #fff;
|
||||
padding: 2.5rem 2rem 2rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
|
||||
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
|
||||
header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
|
||||
|
||||
nav.toc {
|
||||
background: #fff;
|
||||
border: 1px solid #d8e0f0;
|
||||
border-radius: 10px;
|
||||
padding: 1.4rem 1.8rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
nav.toc h2 {
|
||||
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
|
||||
}
|
||||
nav.toc ol {
|
||||
list-style: none; counter-reset: toc;
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
|
||||
}
|
||||
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
nav.toc li::before {
|
||||
content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
|
||||
font-weight: 700; min-width: 1.4rem;
|
||||
}
|
||||
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
|
||||
nav.toc a:hover { text-decoration: underline; }
|
||||
|
||||
section {
|
||||
background: #fff;
|
||||
border: 1px solid #d8e0f0;
|
||||
border-radius: 10px;
|
||||
padding: 1.8rem 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h2.section-title {
|
||||
font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
|
||||
margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 2px solid #85b7d7;
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
}
|
||||
h2.section-title .num {
|
||||
background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
|
||||
width: 1.6rem; height: 1.6rem; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
|
||||
margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
h3:first-child { margin-top: 0; }
|
||||
|
||||
p { margin-bottom: 0.7rem; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
|
||||
ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
|
||||
li { margin-bottom: 0.35rem; }
|
||||
li:last-child { margin-bottom: 0; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
|
||||
th {
|
||||
background: #eef2fa; color: #2e4e8a; font-weight: 700;
|
||||
text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
|
||||
}
|
||||
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
|
||||
tr:nth-child(even) td { background: #f7f9fd; }
|
||||
|
||||
code {
|
||||
background: #eef2fa; color: #1a2d5a;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
background: #eef6fb; border-left: 4px solid #85b7d7;
|
||||
border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
|
||||
margin: 0.9rem 0; font-size: 0.9rem;
|
||||
}
|
||||
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
|
||||
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
|
||||
|
||||
strong { font-weight: 700; }
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
|
||||
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
|
||||
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.back-btn:hover { background: #eef2fa; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
nav.toc ol { grid-template-columns: 1fr; }
|
||||
header h1 { font-size: 1.25rem; }
|
||||
section { padding: 1.2rem 1rem; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; font-size: 12pt; }
|
||||
.page { max-width: none; padding: 0; }
|
||||
header { border-radius: 0; background: #1a2d5a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
section { border: 1px solid #ccc; break-inside: avoid; }
|
||||
nav.toc { break-after: page; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.back-btn { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
|
||||
|
||||
<header>
|
||||
<div class="star">★</div>
|
||||
<div>
|
||||
<h1>{{H1}}</h1>
|
||||
<p>{{SUBTITLE}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
<ol>
|
||||
{{TOC}}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{{SECTIONS}}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Erzeugt public/anleitung.html aus ANLEITUNG.md.
|
||||
*
|
||||
* ANLEITUNG.md ist die einzige Quelle des Handbuchs; die HTML-Fassung wird nie von Hand
|
||||
* bearbeitet (sie steht in .gitignore). Aufruf: `npm run anleitung` — läuft zusätzlich
|
||||
* automatisch vor jedem `npm run build` (prebuild) und damit auch im Docker-Image.
|
||||
*
|
||||
* Abbildung Markdown → HTML:
|
||||
* # Titel → Kopfzeile der Seite (vor „–" = H1, danach = Untertitel)
|
||||
* ## Inhaltsverzeichnis → nav.toc, die Sprungziele werden auf #s1..#sN umgeschrieben
|
||||
* ## N. Titel → <section id="sN"> mit nummeriertem Abschnittskopf
|
||||
* --- → entfällt (reine Trennlinie in der Markdown-Ansicht)
|
||||
* > Text → Hinweisbox .callout
|
||||
* > [!WARNUNG] Text → .callout warn (auch [!WARNING])
|
||||
* > [!ACHTUNG] Text → .callout danger (auch [!CAUTION])
|
||||
* > [!HINWEIS] Text → .callout (auch [!NOTE])
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const wurzel = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const QUELLE = join(wurzel, 'ANLEITUNG.md');
|
||||
const VORLAGE = join(wurzel, 'scripts', 'anleitung.template.html');
|
||||
const ZIEL = join(wurzel, 'public', 'anleitung.html');
|
||||
|
||||
const ALERTS = {
|
||||
WARNUNG: 'callout warn', WARNING: 'callout warn',
|
||||
ACHTUNG: 'callout danger', CAUTION: 'callout danger', DANGER: 'callout danger',
|
||||
HINWEIS: 'callout', NOTE: 'callout',
|
||||
};
|
||||
|
||||
/** Blockquotes werden zu Hinweisboxen, optional mit Variante über [!WARNUNG] o. ä. */
|
||||
function calloutsUmschreiben(html) {
|
||||
return html.replace(/<blockquote>\s*([\s\S]*?)\s*<\/blockquote>/g, (_, inhalt) => {
|
||||
let klasse = 'callout';
|
||||
const markierung = inhalt.match(/^<p>\s*\[!([A-ZÄÖÜ]+)\]\s*/);
|
||||
if (markierung && ALERTS[markierung[1]]) {
|
||||
klasse = ALERTS[markierung[1]];
|
||||
inhalt = inhalt.replace(markierung[0], '<p>');
|
||||
}
|
||||
// Eine einzelne Textzeile braucht kein <p> — das spart eine Zeile Abstand in der Box.
|
||||
const einzeln = inhalt.match(/^<p>([\s\S]*?)<\/p>$/);
|
||||
return `<div class="${klasse}">${einzeln ? einzeln[1] : inhalt}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Markdown-Sprungziele wie #7-drucken auf die Abschnitts-IDs #s7 umschreiben. */
|
||||
function ankerUmschreiben(html) {
|
||||
return html.replace(/href="#(\d+)-[^"]*"/g, (_, nummer) => `href="#s${nummer}"`);
|
||||
}
|
||||
|
||||
function einrücken(html, stufe = ' ') {
|
||||
return html.split('\n').map((z) => (z.trim() ? stufe + z : z)).join('\n');
|
||||
}
|
||||
|
||||
const markdown = readFileSync(QUELLE, 'utf8');
|
||||
|
||||
// 1. Seitentitel aus der H1
|
||||
const h1 = markdown.match(/^#\s+(.+)$/m);
|
||||
if (!h1) throw new Error('ANLEITUNG.md: H1-Überschrift (# Titel) fehlt');
|
||||
const [titelKopf, titelUnter = ''] = h1[1].split(/\s+[–—-]\s+/, 2);
|
||||
|
||||
// 2. In Blöcke je H2 zerlegen
|
||||
const bloecke = markdown
|
||||
.split(/^##\s+/m)
|
||||
.slice(1)
|
||||
.map((b) => {
|
||||
const umbruch = b.indexOf('\n');
|
||||
return {
|
||||
titel: b.slice(0, umbruch).trim(),
|
||||
rumpf: b.slice(umbruch + 1).replace(/^\s*---\s*$/gm, '').trim(),
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Inhaltsverzeichnis: die Nummern der Listenpunkte werden zu #s<N>
|
||||
const tocBlock = bloecke.find((b) => b.titel.toLowerCase() === 'inhaltsverzeichnis');
|
||||
if (!tocBlock) throw new Error('ANLEITUNG.md: Abschnitt „## Inhaltsverzeichnis" fehlt');
|
||||
const toc = [...tocBlock.rumpf.matchAll(/^\s*(\d+)\.\s*\[([^\]]+)\]/gm)]
|
||||
.map(([, nr, text]) => ` <li><a href="#s${nr}">${text}</a></li>`)
|
||||
.join('\n');
|
||||
if (!toc) throw new Error('ANLEITUNG.md: Inhaltsverzeichnis enthält keine Einträge');
|
||||
|
||||
// 4. Nummerierte Abschnitte
|
||||
const abschnitte = [];
|
||||
for (const block of bloecke) {
|
||||
const kopf = block.titel.match(/^(\d+)\.\s+(.*)$/);
|
||||
if (!kopf) continue; // Inhaltsverzeichnis und alles ohne Nummer
|
||||
const [, nummer, titel] = kopf;
|
||||
const rumpf = ankerUmschreiben(calloutsUmschreiben(marked.parse(block.rumpf)));
|
||||
abschnitte.push(
|
||||
` <!-- ── ${nummer}. ${titel} ── -->\n` +
|
||||
` <section id="s${nummer}">\n` +
|
||||
` <h2 class="section-title"><span class="num">${nummer}</span> ${titel}</h2>\n` +
|
||||
`${einrücken(rumpf.trim())}\n` +
|
||||
` </section>`
|
||||
);
|
||||
}
|
||||
if (!abschnitte.length) throw new Error('ANLEITUNG.md: keine nummerierten Abschnitte (## 1. …) gefunden');
|
||||
|
||||
const html = readFileSync(VORLAGE, 'utf8')
|
||||
.replace('{{H1}}', titelKopf.trim())
|
||||
.replace('{{SUBTITLE}}', titelUnter.trim())
|
||||
.replace('{{TOC}}', toc)
|
||||
.replace('{{SECTIONS}}', abschnitte.join('\n\n'));
|
||||
|
||||
writeFileSync(ZIEL, html, 'utf8');
|
||||
console.log(`anleitung.html erzeugt — ${abschnitte.length} Abschnitte, ${html.length} Zeichen`);
|
||||
Reference in New Issue
Block a user