Spiel-Events, Kalenderdateien, Patches-Seite und neues Design

- Kalender: Termine als Spiel-Event markieren (nur diese zählen in der
  Statistik), optionale Uhrzeiten, Termine bearbeiten
- .ics-Download für einzelne und alle kommenden Termine
- Neue Seite Patches: Upload (PNG, JPEG, WebP, geprüftes SVG),
  Status "Gibt es schon" / "In Planung", Bilder in data/patches/
- Volle Seitenbreite mit Spaltenlayouts, neues Design mit selbst
  gehosteten Schriften, Startseite mit Zoom und drehendem Zahnrad
- Bestehende Daten werden beim Laden ergänzt, keine Migration nötig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-12 00:13:52 +02:00
1 parent 43356a120c
commit 486e6f6784
23 files changed
+1702 -332

No files matched your search

+21 -8
View File
@@ -21,8 +21,11 @@ Anderen Port wählen: `PORT=8080 npm start`.
## Seiten ## Seiten
- **Start** (`/`) – Startseite mit Platzhaltertext, Links zu Kalender und Fahrtplaner, nächste Spieltage. - **Start** (`/`) – Startseite mit Flagge, die beim Scrollen heranzoomt, nächstem Spiel samt Countdown,
- **Kalender** (`/kalender`) – Mitglieder wählen „Ich bin …“, tragen Spieltage ein und sagen zu oder ab. Links zu allen Bereichen, neuesten Patches, nächsten Terminen und „Pile of Shame“.
- **Kalender** (`/kalender`) – Mitglieder wählen „Ich bin …“, tragen Termine ein (optional mit Beginn und Ende), bearbeiten sie und sagen zu oder ab.
Pro Termin wird angekreuzt, ob es ein **Spiel-Event** ist – nur diese zählen in der Statistik (Treffen, Grillen usw. nicht).
Einzelne Termine oder alle kommenden lassen sich als `.ics`-Datei für Google, Apple oder Outlook herunterladen.
Über „Fahrt planen“ geht es direkt in den Fahrtplaner, alle Zusagen sind dann schon als „kommt mit“ ausgewählt. Über „Fahrt planen“ geht es direkt in den Fahrtplaner, alle Zusagen sind dann schon als „kommt mit“ ausgewählt.
- **Fahrtplaner** (`/fahrt`) – Treffpunkt, Abfahrt (Standard 07:00) und Treffzeit (15 min vorher) festlegen – nach der Berechnung pro Auto änderbar, Mitfahrer auswählen - **Fahrtplaner** (`/fahrt`) – Treffpunkt, Abfahrt (Standard 07:00) und Treffzeit (15 min vorher) festlegen – nach der Berechnung pro Auto änderbar, Mitfahrer auswählen
und pro Fahrt angeben, wer sein Auto dabei hat. Wer immer mit eigenem Auto kommt, fährt immer selbst und bekommt zuerst Mitfahrer. und pro Fahrt angeben, wer sein Auto dabei hat. Wer immer mit eigenem Auto kommt, fährt immer selbst und bekommt zuerst Mitfahrer.
@@ -30,17 +33,22 @@ Anderen Port wählen: `PORT=8080 npm start`.
Unter „Feste Zuteilung“ lässt sich vorab festlegen, wer diesmal sicher bei wem mitfährt, Gäste nur für diese Fahrt hinzufügen, Fahrgemeinschaften berechnen, Unter „Feste Zuteilung“ lässt sich vorab festlegen, wer diesmal sicher bei wem mitfährt, Gäste nur für diese Fahrt hinzufügen, Fahrgemeinschaften berechnen,
Ergebnis kopieren oder drucken. „Eingabe zurücksetzen“ leert alles für die nächste Planung. Ergebnis kopieren oder drucken. „Eingabe zurücksetzen“ leert alles für die nächste Planung.
- **Statistik** (`/statistik`, öffentlich) – wie oft jedes Mitglied (ohne Externe) im laufenden Jahr gespielt hat: - **Statistik** (`/statistik`, öffentlich) – wie oft jedes Mitglied (ohne Externe) im laufenden Jahr gespielt hat:
Spieltage mit Zusage aus dem Kalender plus manuelle Korrektur (− / +, nur angemeldet), z. B. für spontane Fahrten. Spiel-Events mit Zusage aus dem Kalender plus manuelle Korrektur (− / +, nur angemeldet), z. B. für spontane Fahrten.
Zu Neujahr wird der Endstand des Vorjahres archiviert (im Jahresmenü abrufbar) und die Zählung beginnt bei 0. Zu Neujahr wird der Endstand des Vorjahres archiviert (im Jahresmenü abrufbar) und die Zählung beginnt bei 0.
Die Startseite zeigt als „Pile of Shame“, wer im laufenden Jahr noch nicht spielen war. Die Startseite zeigt als „Pile of Shame“, wer im laufenden Jahr noch nicht spielen war.
Der Jahreswechsel richtet sich nach der Zeitzone des Servers (im systemd-Dienst `Environment=TZ=Europe/Berlin` setzen). Der Jahreswechsel richtet sich nach der Zeitzone des Servers (im systemd-Dienst `Environment=TZ=Europe/Berlin` setzen).
- **Patches** (`/patches`, öffentlich) – Galerie der Gruppen- und Event-Patches, getrennt nach „Gibt es schon“ und „In Planung“.
Angemeldet: Patch hochladen (PNG, JPEG oder WebP – wird im Browser auf max. 1600 px verkleinert – oder SVG bis 2 MB),
bearbeiten, löschen. SVGs mit Skripten, Ereignis-Attributen oder Verweisen auf fremde Dateien werden abgelehnt
und zusätzlich mit einer Content-Security-Policy ausgeliefert.
Die Bilder liegen in `data/patches/` und sind damit im Docker-Volume und im Backup enthalten.
- **Gruppe** (`/gruppe`) – feste Mitglieder (Vorname, extern ja/nein, Auto ja/nein, freie Plätze ohne Fahrer, „kommt immer mit eigenem Auto“, - **Gruppe** (`/gruppe`) – feste Mitglieder (Vorname, extern ja/nein, Auto ja/nein, freie Plätze ohne Fahrer, „kommt immer mit eigenem Auto“,
Standard-Treffpunkt als Fahrer, „fährt am liebsten mit bei …“) und Treffpunkte (Name, Adresse) verwalten, Export/Import der Mitglieder als JSON. Standard-Treffpunkt als Fahrer, „fährt am liebsten mit bei …“) und Treffpunkte (Name, Adresse) verwalten, Export/Import der Mitglieder als JSON.
## Login ## Login
Öffentlich sind nur die Startseite (inkl. der nächsten 3 Spieltage: Datum, Titel, Anzahl Zusagen) und die Statistik Öffentlich sind nur die Startseite (inkl. der nächsten 3 Termine: Datum, Uhrzeit, Titel, Anzahl Zusagen), die Statistik
(Namen und Anzahl gespielter Spieltage). (Namen und Anzahl gespielter Spieltage) und die Patches.
Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar. Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar.
Benutzer werden auf dem Server per Kommandozeile verwaltet: Benutzer werden auf dem Server per Kommandozeile verwaltet:
@@ -63,11 +71,11 @@ Sicherheit:
## Aufbau ## Aufbau
``` ```
server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points) server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points, /api/patches, .ics-Export)
auth.js Login, Passwort-Hashing, Sitzungen auth.js Login, Passwort-Hashing, Sitzungen
scripts/user.js Benutzerverwaltung (npm run user) scripts/user.js Benutzerverwaltung (npm run user)
data/ db.json, users.json, sessions.json (automatisch angelegt, nicht in Git) data/ db.json, users.json, sessions.json, patches/ (automatisch angelegt, nicht in Git)
public/ öffentlich: Startseite, Login, CSS, Bilder, common.js public/ öffentlich: Startseite, Statistik, Patches, Login, CSS, Bilder, Schriften, common.js
private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe
``` ```
@@ -76,3 +84,8 @@ private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe
Per Docker hinter dem Nginx Proxy Manager: `docker compose up -d --build` (Daten liegen in `./data`, Per Docker hinter dem Nginx Proxy Manager: `docker compose up -d --build` (Daten liegen in `./data`,
die Seite läuft auf Port 3000, änderbar per `HCRB_PORT` in `.env`). Schritt-für-Schritt-Anleitung inkl. Domain, HTTPS, Updates und Backups: die Seite läuft auf Port 3000, änderbar per `HCRB_PORT` in `.env`). Schritt-für-Schritt-Anleitung inkl. Domain, HTTPS, Updates und Backups:
[DEPLOY.md](DEPLOY.md). Skripte für Updates und Backups liegen in `deploy/`. [DEPLOY.md](DEPLOY.md). Skripte für Updates und Backups liegen in `deploy/`.
## Schriften
Die Schriften liegen in `public/fonts/` und werden vom eigenen Server ausgeliefert (keine Anfrage an Google, DSGVO):
Saira Stencil One (Schriftzug „HCRB“), Barlow und Barlow Condensed. Alle unter der SIL Open Font License 1.1.
+27 -16
View File
@@ -7,9 +7,19 @@
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<main class="narrow"> <main>
<header class="page-head no-print">
<div>
<span class="eyebrow">Fahrgemeinschaften</span>
<h1>Fahrtplaner</h1>
<p>Wer fährt, wer steigt wo zu, wann geht es los.</p>
</div>
</header>
<div id="from-event" class="notice no-print" hidden></div> <div id="from-event" class="notice no-print" hidden></div>
<div class="split">
<div class="col">
<div class="card no-print"> <div class="card no-print">
<div class="card-head"> <div class="card-head">
<h2>Fahrt</h2> <h2>Fahrt</h2>
@@ -36,19 +46,6 @@
</label> </label>
</div> </div>
</div> </div>
<div class="card no-print">
<div class="card-head">
<h2>Wer kommt mit?</h2>
<div class="row">
<button type="button" id="select-all" class="small">Alle</button>
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
<div class="card no-print"> <div class="card no-print">
<h2>Weitere Personen (nur diese Fahrt)</h2> <h2>Weitere Personen (nur diese Fahrt)</h2>
<form id="guest-form" class="row wrap"> <form id="guest-form" class="row wrap">
@@ -60,7 +57,6 @@
</form> </form>
<ul id="guests-list" class="list"></ul> <ul id="guests-list" class="list"></ul>
</div> </div>
<div class="card no-print"> <div class="card no-print">
<h2>Feste Zuteilung (nur diese Fahrt)</h2> <h2>Feste Zuteilung (nur diese Fahrt)</h2>
<p class="muted">Wer hier eingetragen ist, fährt sicher bei diesem Fahrer mit – der Fahrer fährt dann auf jeden Fall. <p class="muted">Wer hier eingetragen ist, fährt sicher bei diesem Fahrer mit – der Fahrer fährt dann auf jeden Fall.
@@ -73,8 +69,23 @@
</form> </form>
<ul id="fixed-list" class="list"></ul> <ul id="fixed-list" class="list"></ul>
</div> </div>
</div>
<div class="col">
<div class="card no-print">
<div class="card-head">
<h2>Wer kommt mit?</h2>
<div class="row">
<button type="button" id="select-all" class="small">Alle</button>
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
</div>
</div>
<div class="card"> <div class="card" style="margin-top: 20px">
<h2 class="no-print">Einteilung</h2> <h2 class="no-print">Einteilung</h2>
<div class="no-print"> <div class="no-print">
<label class="inline"> <label class="inline">
+23 -8
View File
@@ -7,7 +7,17 @@
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<main class="narrow"> <main>
<header class="page-head">
<div>
<span class="eyebrow">Mitglieder &amp; Treffpunkte</span>
<h1>Gruppe</h1>
<p>Wer gehört dazu, wer hat ein Auto und wo trifft man sich.</p>
</div>
</header>
<div class="split wide-right">
<div class="col">
<div class="card"> <div class="card">
<h2 id="member-form-title">Mitglied hinzufügen</h2> <h2 id="member-form-title">Mitglied hinzufügen</h2>
<form id="member-form"> <form id="member-form">
@@ -42,13 +52,6 @@
</div> </div>
</form> </form>
</div> </div>
<div class="card">
<h2>Gruppe</h2>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card"> <div class="card">
<h2 id="point-form-title">Treffpunkt hinzufügen</h2> <h2 id="point-form-title">Treffpunkt hinzufügen</h2>
<form id="point-form"> <form id="point-form">
@@ -67,7 +70,14 @@
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p> <p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
<ul id="points-list" class="list"></ul> <ul id="points-list" class="list"></ul>
</div> </div>
</div>
<div class="col">
<div class="card">
<h2>Gruppe</h2>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card"> <div class="card">
<h2>Daten sichern</h2> <h2>Daten sichern</h2>
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p> <p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
@@ -78,6 +88,11 @@
</label> </label>
</div> </div>
</div> </div>
</div>
</div>
</main> </main>
<script type="module" src="/js/gruppe.js"></script> <script type="module" src="/js/gruppe.js"></script>
+82 -26
View File
@@ -1,6 +1,6 @@
import { import {
$, api, esc, sortByName, showError, renderHeader, $, api, esc, sortByName, showError, renderHeader,
toIso, fromIso, formatDate, getMe, setMe, toIso, fromIso, formatDate, formatTime, getMe, setMe,
} from './common.js'; } from './common.js';
renderHeader('kalender'); renderHeader('kalender');
@@ -8,6 +8,7 @@ renderHeader('kalender');
let members = []; let members = [];
let events = []; let events = [];
let me = getMe(); let me = getMe();
let editingId = null; // Termin, der gerade im Formular bearbeitet wird
const todayIso = toIso(new Date()); const todayIso = toIso(new Date());
let selectedDay = todayIso; let selectedDay = todayIso;
@@ -23,7 +24,7 @@ function renderMe() {
sortByName(members).map((m) => sortByName(members).map((m) =>
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join(''); `<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
$('#me-hint').hidden = !!me; $('#me-hint').hidden = !!me;
$('#event-form button[type=submit]').disabled = !me; $('#event-submit').disabled = !me && !editingId;
} }
$('#me-select').addEventListener('change', (e) => { $('#me-select').addEventListener('change', (e) => {
@@ -53,14 +54,15 @@ function renderCalendar() {
const iso = toIso(day); const iso = toIso(day);
const classes = ['cal-day']; const classes = ['cal-day'];
if (day.getMonth() !== viewMonth.getMonth()) classes.push('other'); if (day.getMonth() !== viewMonth.getMonth()) classes.push('other');
if (day.getDay() === 0 || day.getDay() === 6) classes.push('weekend');
if (iso === todayIso) classes.push('today'); if (iso === todayIso) classes.push('today');
if (iso === selectedDay) classes.push('selected'); if (iso === selectedDay) classes.push('selected');
const dayEvents = byDate.get(iso) || []; const dayEvents = byDate.get(iso) || [];
html += ` html += `
<button class="${classes.join(' ')}" data-day="${iso}"> <button class="${classes.join(' ')}" data-day="${iso}">
<span class="num">${day.getDate()}</span> <span class="num">${day.getDate()}</span>
${dayEvents.map((ev) => `<span class="ev ${ev.attendees.includes(me) ? 'mine' : ''}" ${dayEvents.map((ev) => `<span class="ev ${ev.isGame ? '' : 'nogame'} ${ev.attendees.includes(me) ? 'mine' : ''}"
title="${esc(ev.title)} (${ev.attendees.length} zugesagt)">${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')} title="${esc(ev.title)}${ev.isGame ? '' : ' (kein Spiel)'} · ${ev.attendees.length} zugesagt">${ev.time ? `${ev.time} ` : ''}${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
</button>`; </button>`;
day.setDate(day.getDate() + 1); day.setDate(day.getDate() + 1);
} }
@@ -70,34 +72,43 @@ function renderCalendar() {
$('#calendar').addEventListener('click', (e) => { $('#calendar').addEventListener('click', (e) => {
const cell = e.target.closest('[data-day]'); const cell = e.target.closest('[data-day]');
if (!cell) return; if (!cell) return;
selectedDay = cell.dataset.day; selectDay(cell.dataset.day);
const d = fromIso(selectedDay); // Auf schmalen Bildschirmen stehen die Details unter dem Kalender
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1); if (matchMedia('(max-width: 999px)').matches) $('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
renderAll();
$('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
}); });
function selectDay(iso) {
selectedDay = iso;
const d = fromIso(iso);
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
if (!editingId) $('#event-date').value = iso;
renderAll();
}
function shiftMonth(delta) { function shiftMonth(delta) {
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1); viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
renderCalendar(); renderCalendar();
} }
$('#prev-month').addEventListener('click', () => shiftMonth(-1)); $('#prev-month').addEventListener('click', () => shiftMonth(-1));
$('#next-month').addEventListener('click', () => shiftMonth(1)); $('#next-month').addEventListener('click', () => shiftMonth(1));
$('#today-btn').addEventListener('click', () => { $('#today-btn').addEventListener('click', () => selectDay(todayIso));
selectedDay = todayIso;
viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
renderAll();
});
// ---------- Termine ---------- // ---------- Termine ----------
const downloadIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 21h16"/></svg>';
function eventHtml(ev) { function eventHtml(ev) {
const going = ev.attendees.includes(me); const going = ev.attendees.includes(me);
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) }))); const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
const when = [formatDate(ev.date, true), formatTime(ev)].filter(Boolean).join(' · ');
return ` return `
<div class="event"> <div class="event ${ev.isGame ? '' : 'nogame'}">
<div class="event-top">
<h3>${esc(ev.title)}</h3> <h3>${esc(ev.title)}</h3>
<div class="when">${formatDate(ev.date, true)}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div> ${ev.isGame
? '<span class="badge game" title="Zählt für die Statistik">Spiel</span>'
: '<span class="badge nogame" title="Zählt nicht für die Statistik">Kein Spiel</span>'}
</div>
<div class="when">${when}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div>
${ev.note ? `<div class="note">${esc(ev.note)}</div>` : ''} ${ev.note ? `<div class="note">${esc(ev.note)}</div>` : ''}
<div class="muted">${ev.attendees.length} zugesagt:</div> <div class="muted">${ev.attendees.length} zugesagt:</div>
<div class="chips"> <div class="chips">
@@ -110,6 +121,8 @@ function eventHtml(ev) {
? `<button data-action="leave" data-id="${ev.id}">Absagen</button>` ? `<button data-action="leave" data-id="${ev.id}">Absagen</button>`
: `<button class="primary" data-action="join" data-id="${ev.id}">Ich bin dabei</button>`) : ''} : `<button class="primary" data-action="join" data-id="${ev.id}">Ich bin dabei</button>`) : ''}
<a class="button" href="/fahrt?termin=${ev.id}">Fahrt planen</a> <a class="button" href="/fahrt?termin=${ev.id}">Fahrt planen</a>
<a class="button" href="/api/events/${ev.id}/ics" download title="Termin als .ics-Datei in den eigenen Kalender übernehmen">${downloadIcon}Kalender</a>
<button class="link" data-action="edit" data-id="${ev.id}">Bearbeiten</button>
<button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button> <button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button>
</div> </div>
</div>`; </div>`;
@@ -122,14 +135,14 @@ function renderDay() {
const dayEvents = events.filter((e) => e.date === selectedDay); const dayEvents = events.filter((e) => e.date === selectedDay);
$('#day-events').innerHTML = dayEvents.length $('#day-events').innerHTML = dayEvents.length
? dayEvents.map(eventHtml).join('') ? dayEvents.map(eventHtml).join('')
: '<p class="muted">An diesem Tag ist noch kein Spieltag eingetragen.</p>'; : '<p class="muted">An diesem Tag ist noch nichts eingetragen.</p>';
} }
function renderUpcoming() { function renderUpcoming() {
const upcoming = events.filter((e) => e.date >= todayIso); const upcoming = events.filter((e) => e.date >= todayIso);
$('#upcoming').innerHTML = upcoming.length $('#upcoming').innerHTML = upcoming.length
? upcoming.map(eventHtml).join('') ? upcoming.map(eventHtml).join('')
: '<p class="muted">Keine kommenden Spieltage.</p>'; : '<p class="muted">Keine kommenden Termine.</p>';
} }
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
@@ -137,12 +150,14 @@ document.addEventListener('click', async (e) => {
if (!btn) return; if (!btn) return;
const { action, id } = btn.dataset; const { action, id } = btn.dataset;
try { try {
if (action === 'edit') return startEdit(id);
if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`); if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`);
if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`); if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`);
if (action === 'delete') { if (action === 'delete') {
const ev = events.find((x) => x.id === id); const ev = events.find((x) => x.id === id);
if (!confirm(`Spieltag „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return; if (!confirm(`Termin „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
await api('DELETE', `/events/${id}`); await api('DELETE', `/events/${id}`);
if (editingId === id) stopEdit();
} }
await load(); await load();
} catch (err) { } catch (err) {
@@ -150,17 +165,57 @@ document.addEventListener('click', async (e) => {
} }
}); });
// ---------- Formular: eintragen und bearbeiten ----------
function startEdit(id) {
const ev = events.find((x) => x.id === id);
if (!ev) return;
editingId = id;
$('#event-form-title').textContent = 'Termin bearbeiten';
$('#event-submit').textContent = 'Speichern';
$('#event-cancel').hidden = false;
$('#event-title').value = ev.title;
$('#event-game').checked = ev.isGame;
$('#event-date').value = ev.date;
$('#event-time').value = ev.time || '';
$('#event-end').value = ev.endTime || '';
$('#event-note').value = ev.note || '';
renderMe();
$('#event-form').scrollIntoView({ behavior: 'smooth', block: 'center' });
$('#event-title').focus({ preventScroll: true });
}
function stopEdit() {
editingId = null;
$('#event-form').reset();
$('#event-form-title').textContent = 'Termin an diesem Tag eintragen';
$('#event-submit').textContent = 'Eintragen';
$('#event-cancel').hidden = true;
$('#event-date').value = selectedDay;
renderMe();
}
$('#event-cancel').addEventListener('click', stopEdit);
$('#event-form').addEventListener('submit', async (e) => { $('#event-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
try { const body = {
await api('POST', '/events', { date: $('#event-date').value,
date: selectedDay,
title: $('#event-title').value, title: $('#event-title').value,
note: $('#event-note').value, note: $('#event-note').value,
createdBy: me, isGame: $('#event-game').checked,
}); time: $('#event-time').value,
e.target.reset(); endTime: $('#event-end').value,
};
try {
if (editingId) {
await api('PUT', `/events/${editingId}`, body);
} else {
await api('POST', '/events', { ...body, createdBy: me });
}
const date = body.date;
stopEdit();
await load(); await load();
if (date && date !== selectedDay) selectDay(date);
} catch (err) { } catch (err) {
showError(err); showError(err);
} }
@@ -179,4 +234,5 @@ async function load() {
renderAll(); renderAll();
} }
$('#event-date').value = selectedDay;
load().catch(showError); load().catch(showError);
+64 -14
View File
@@ -8,42 +8,92 @@
</head> </head>
<body> <body>
<main> <main>
<div class="card"> <header class="page-head">
<label>Ich bin <div>
<select id="me-select"></select> <span class="eyebrow">Spieltage &amp; Termine</span>
</label> <h1>Kalender</h1>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Spieltage eintragen und zusagen kannst. <p>Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.</p>
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
</div> </div>
<div class="actions">
<a class="button" href="/api/calendar.ics" download title="Alle kommenden Termine als .ics-Datei für Google, Apple oder Outlook">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 21h16"/></svg>
Alle Termine (.ics)
</a>
</div>
</header>
<div class="split wide-left">
<div class="col">
<div class="card"> <div class="card">
<div class="cal-nav"> <div class="cal-nav">
<button id="prev-month" aria-label="Vorheriger Monat">‹</button> <button id="prev-month" aria-label="Vorheriger Monat">‹</button>
<h2 id="month-title"></h2> <h2 id="month-title"></h2>
<button id="next-month" aria-label="Nächster Monat">›</button> <button id="next-month" aria-label="Nächster Monat">›</button>
<button id="today-btn" class="small">Heute</button>
</div> </div>
<div class="row end"><button id="today-btn" class="small">Heute</button></div>
<div id="calendar" class="cal-grid"></div> <div id="calendar" class="cal-grid"></div>
<div class="cal-legend">
<span><i class="l-game"></i>Spiel-Event</span>
<span><i class="l-nogame"></i>Termin ohne Spiel</span>
<span><i class="l-mine"></i>Du hast zugesagt</span>
</div>
</div>
</div>
<div class="col">
<div class="card">
<label>Ich bin
<select id="me-select"></select>
</label>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Termine eintragen und zusagen kannst.
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
</div> </div>
<div class="card"> <div class="card">
<h2 id="day-title"></h2> <h2 id="day-title"></h2>
<div id="day-events"></div> <div id="day-events"></div>
<form id="event-form"> <form id="event-form">
<h3>Spieltag an diesem Tag eintragen</h3> <h3 id="event-form-title">Termin an diesem Tag eintragen</h3>
<label>Titel / Spielfeld <label>Titel / Spielfeld
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Waldfeld"> <input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Darkzone">
</label> </label>
<label class="toggle-field">
<input type="checkbox" id="event-game" checked>
<div>
<strong>Spiel-Event</strong>
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen, Grillen oder Basteltage das Häkchen entfernen.</span>
</div>
</label>
<div class="grid2">
<label>Datum
<input type="date" id="event-date" required>
</label>
<div class="grid2 keep">
<label>Beginn
<input type="time" id="event-time">
</label>
<label>Ende
<input type="time" id="event-end">
</label>
</div>
</div>
<label>Notiz (optional) <label>Notiz (optional)
<textarea id="event-note" maxlength="500" placeholder="Uhrzeit, Link zum Event, Treffpunkt …"></textarea> <textarea id="event-note" maxlength="500" placeholder="Link zum Event, Treffpunkt, Ausrüstung …"></textarea>
</label> </label>
<button type="submit" class="primary">Eintragen</button> <div class="row wrap">
<button type="submit" class="primary" id="event-submit">Eintragen</button>
<button type="button" id="event-cancel" hidden>Abbrechen</button>
</div>
</form> </form>
</div> </div>
</div>
<div class="card"> <section class="span-all">
<h2>Kommende Spieltage</h2> <div class="section-title">
<div id="upcoming"></div> <h2>Kommende Termine</h2>
</div>
<div id="upcoming" class="events-grid"></div>
</section>
</div> </div>
</main> </main>
+709 -179
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+73 -59
View File
@@ -4,50 +4,93 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>HCRB – Highcap Rasselbande</title> <title>HCRB – Highcap Rasselbande</title>
<meta name="description" content="Highcap Rasselbande (HCRB), Airsoft-Gruppe aus Berlin.">
<meta name="theme-color" content="#121730">
<link rel="preload" href="/fonts/saira-stencil-one-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body class="home">
<section class="hero"> <section class="hero" aria-label="HCRB">
<div> <div class="hero-stage" id="hero-stage">
<!-- Flagge in zwei Ebenen: Hintergrund mit Bär steht, das Zahnrad darüber dreht sich beim Scrollen -->
<img class="hero-flag" src="/img/flagge-grund.jpg" alt="Flagge der HCRB: Berliner Bär in einem Zahnrad" width="1600" height="1067" fetchpriority="high">
<img class="hero-flag hero-gear" src="/img/zahnrad.webp" alt="" width="1600" height="1067" fetchpriority="high">
<div class="hero-shade"></div>
<div class="hud" aria-hidden="true">
<span class="tl">52.520° N · 13.405° E</span>
<span class="tr">Airsoft · Berlin</span>
<span class="corner c1"></span><span class="corner c2"></span>
<span class="corner c3"></span><span class="corner c4"></span>
</div>
<div class="hero-title">
<h1>HCRB</h1> <h1>HCRB</h1>
<div class="subtitle">Highcap Rasselbande</div> <div class="subtitle">Highcap Rasselbande</div>
</div> </div>
</div>
</section> </section>
<main> <main>
<p class="intro"> <section class="next-op reveal" id="next-op">
Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin. <div>
Am häufigsten findet ihr uns auf der Darkzone. <span class="eyebrow">Nächster Einsatz</span>
</p> <h2 id="next-title">Lädt …</h2>
<div class="when" id="next-when"></div>
</div>
<div class="countdown" id="next-countdown" hidden></div>
</section>
<p class="notice wip"> <nav class="areas reveal" aria-label="Bereiche">
<strong>Diese Seite ist noch in Arbeit.</strong> Es kommen noch Änderungen und neue Funktionen dazu. <a class="tile big" href="/kalender">
</p> <svg class="bg-gear" viewBox="0 0 100 100" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M87.8 40.4 L97.5 43.1 L97.5 56.9 L87.8 59.6 L87.5 60.6 L94.6 67.8 L87.7 79.7 L77.9 77.2 L77.2 77.9 L79.7 87.7 L67.8 94.6 L60.6 87.5 L59.6 87.8 L56.9 97.5 L43.1 97.5 L40.4 87.8 L39.4 87.5 L32.2 94.6 L20.3 87.7 L22.8 77.9 L22.1 77.2 L12.3 79.7 L5.4 67.8 L12.5 60.6 L12.2 59.6 L2.5 56.9 L2.5 43.1 L12.2 40.4 L12.5 39.4 L5.4 32.2 L12.3 20.3 L22.1 22.8 L22.8 22.1 L20.3 12.3 L32.2 5.4 L39.4 12.5 L40.4 12.2 L43.1 2.5 L56.9 2.5 L59.6 12.2 L60.6 12.5 L67.8 5.4 L79.7 12.3 L77.2 22.1 L77.9 22.8 L87.7 20.3 L94.6 32.2 L87.5 39.4Z M50 28a22 22 0 1 0 0 44a22 22 0 1 0 0-44Z"/></svg>
<div class="tiles">
<a class="tile" href="/kalender">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<rect x="3" y="5" width="18" height="16"/><path d="M3 10h18M8 3v4M16 3v4M7 14h3M14 14h3M7 17h3"/> <rect x="3" y="5" width="18" height="16"/><path d="M3 10h18M8 3v4M16 3v4M7 14h3M14 14h3M7 17h3"/>
</svg> </svg>
<div>
<h2>Kalender</h2> <h2>Kalender</h2>
<p>Spieltage eintragen und zusagen.</p> <p>Spieltage und Termine eintragen, zusagen und als Kalenderdatei aufs Handy holen.</p>
</div> <span class="go">Zum Kalender</span>
</a> </a>
<a class="tile" href="/fahrt"> <a class="tile" href="/fahrt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<path d="M3 16v-4l2-5h14l2 5v4H3z"/><path d="M3 12h18"/><circle cx="7.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/> <path d="M3 16v-4l2-5h14l2 5v4H3z"/><path d="M3 12h18"/><circle cx="7.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/>
</svg> </svg>
<div>
<h2>Fahrtplaner</h2> <h2>Fahrtplaner</h2>
<p>Fahrgemeinschaften einteilen.</p> <p>Fahrgemeinschaften einteilen.</p>
</div>
</a> </a>
</div> <a class="tile" href="/patches">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<path d="M12 2.5l8 3.5v6c0 5-3.5 8.2-8 9.5-4.5-1.3-8-4.5-8-9.5V6z"/><path d="M8.5 12l2.5 2.5 4.5-5"/>
</svg>
<h2>Patches</h2>
<p>Alle Patches der Bande.</p>
</a>
<a class="tile" href="/statistik">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>
</svg>
<h2>Statistik</h2>
<p>Wer war wie oft spielen?</p>
</a>
<a class="tile" href="/gruppe">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.8c2 .7 3.2 2.4 3.5 5.2"/>
</svg>
<h2>Gruppe</h2>
<p>Mitglieder und Treffpunkte.</p>
</a>
</nav>
<section id="patch-section" class="reveal" hidden>
<div class="section-title">
<h2>Unsere Patches</h2>
<a href="/patches" class="button small">Alle Patches</a>
</div>
<div class="patch-rail" id="patch-rail"></div>
</section>
<div class="home-grid reveal">
<div class="card"> <div class="card">
<div class="card-head"> <div class="card-head">
<h2>Nächste Spieltage</h2> <h2>Nächste Termine</h2>
<a href="/kalender" class="button small">Zum Kalender</a> <a href="/kalender" class="button small">Zum Kalender</a>
</div> </div>
<div id="upcoming"><p class="muted">Lädt …</p></div> <div id="upcoming"><p class="muted">Lädt …</p></div>
@@ -60,16 +103,20 @@
</div> </div>
<div id="shame"><p class="muted">Lädt …</p></div> <div id="shame"><p class="muted">Lädt …</p></div>
</div> </div>
</div>
<div class="card"> <section class="about reveal">
<h2>Über uns</h2> <img src="/img/logo.png" alt="" width="128" height="128">
<div>
<span class="eyebrow">Über uns</span>
<h2>Highcap Rasselbande</h2>
<p> <p>
Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin. Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin.
Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong> – dort sind wir regelmäßig unterwegs. Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong>, dort sind wir regelmäßig unterwegs.
</p> </p>
<p> <p>
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
gibt es auf YouTube und Instagram: gibt es auf YouTube und Instagram.
</p> </p>
<div class="social"> <div class="social">
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener"> <a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
@@ -80,42 +127,9 @@
</a> </a>
</div> </div>
</div> </div>
</section>
</main> </main>
<script type="module" src="/js/home.js"></script>
<script type="module">
import { renderHeader, renderFooter, api, esc, formatDate } from '/js/common.js';
renderHeader('home');
renderFooter();
const box = document.getElementById('upcoming');
try {
const events = await api('GET', '/upcoming');
box.innerHTML = events.length
? `<ul class="list">${events.map((e) => `
<li>
<span class="name"><strong>${esc(e.title)}</strong></span>
<span class="meta">${formatDate(e.date, true)}</span>
<span class="badge">${e.attendeeCount} zugesagt</span>
</li>`).join('')}</ul>`
: '<p class="muted">Noch keine Spieltage eingetragen.</p>';
} catch {
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
}
// Pile of Shame: wer dieses Jahr noch nicht spielen war
const shame = document.getElementById('shame');
try {
const { year, entries } = await api('GET', '/stats');
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
shame.innerHTML = names.length
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
: `<p class="muted">Leer – alle waren ${year} schon spielen.</p>`;
} catch {
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
}
</script>
</body> </body>
</html> </html>
+24 -4
View File
@@ -80,11 +80,13 @@ const NAV = [
{ href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' }, { href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' },
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' }, { href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
{ href: '/statistik', key: 'statistik', label: 'Statistik' }, { href: '/statistik', key: 'statistik', label: 'Statistik' },
{ href: '/patches', key: 'patches', label: 'Patches' },
]; ];
export function renderHeader(active) { // overlay: Kopfzeile liegt durchsichtig über dem Hero und wird erst beim Scrollen fest (Startseite)
export function renderHeader(active, { overlay = false } = {}) {
const header = document.createElement('header'); const header = document.createElement('header');
header.className = 'site-header'; header.className = overlay ? 'site-header overlay' : 'site-header';
header.innerHTML = ` header.innerHTML = `
<div class="inner"> <div class="inner">
<a class="brand" href="/"><img src="/img/logo.png" alt="" width="40" height="40">HCRB<small>Highcap Rasselbande</small></a> <a class="brand" href="/"><img src="/img/logo.png" alt="" width="40" height="40">HCRB<small>Highcap Rasselbande</small></a>
@@ -98,6 +100,11 @@ export function renderHeader(active) {
</nav> </nav>
</div>`; </div>`;
document.body.prepend(header); document.body.prepend(header);
if (overlay) {
const update = () => header.classList.toggle('scrolled', scrollY > 40);
addEventListener('scroll', update, { passive: true });
update();
}
// Auf schmalen Bildschirmen klappt das Menü über die Schaltfläche auf // Auf schmalen Bildschirmen klappt das Menü über die Schaltfläche auf
const toggle = header.querySelector('.nav-toggle'); const toggle = header.querySelector('.nav-toggle');
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
@@ -114,7 +121,7 @@ export function renderHeader(active) {
export function renderFooter() { export function renderFooter() {
const footer = document.createElement('footer'); const footer = document.createElement('footer');
footer.innerHTML = ` footer.innerHTML = `
HCRB · Highcap Rasselbande · Berlin <span class="f-brand"><img src="/img/logo.png" alt="" width="32" height="32">HCRB · Highcap Rasselbande · Berlin</span>
<div class="footer-links"> <div class="footer-links">
<a href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">YouTube</a> <a href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">YouTube</a>
<a href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">Instagram</a> <a href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">Instagram</a>
@@ -122,8 +129,15 @@ export function renderFooter() {
document.body.append(footer); document.body.append(footer);
} }
// Einmal pro Seite abfragen, ob jemand angemeldet ist
let mePromise;
export function currentUser() {
mePromise ??= api('GET', '/me').then(({ user }) => user || null).catch(() => null);
return mePromise;
}
async function renderAuthLink(nav) { async function renderAuthLink(nav) {
const { user } = await api('GET', '/me').catch(() => ({})); const user = await currentUser();
if (!user) { if (!user) {
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>'); nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
return; return;
@@ -138,3 +152,9 @@ async function renderAuthLink(nav) {
}); });
nav.append(btn); nav.append(btn);
} }
// Kurzes Zeitformat für Termine: "09:00 Uhr" oder "09:00–15:00 Uhr"
export function formatTime(ev) {
if (!ev.time) return '';
return `${ev.time}${ev.endTime ? `–${ev.endTime}` : ''} Uhr`;
}
+125
View File
@@ -0,0 +1,125 @@
import { renderHeader, renderFooter, api, esc, formatDate, fromIso, toIso, formatTime } from './common.js';
renderHeader('home', { overlay: true });
renderFooter();
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
// ---------- Scrollen: Flagge zoomt heran ----------
// Eine einzige rAF-Schleife, die nur läuft, wenn gescrollt wurde.
const stage = document.getElementById('hero-stage');
const hero = stage.parentElement;
let ticking = false;
function frame() {
ticking = false;
if (reduceMotion.matches) {
stage.style.setProperty('--p', 0);
return;
}
const range = Math.max(1, hero.offsetHeight - innerHeight);
const p = Math.min(1, Math.max(0, scrollY / range));
stage.style.setProperty('--p', p.toFixed(4));
}
function requestFrame() {
if (!ticking) {
ticking = true;
requestAnimationFrame(frame);
}
}
addEventListener('scroll', requestFrame, { passive: true });
addEventListener('resize', requestFrame);
reduceMotion.addEventListener('change', requestFrame);
frame();
// ---------- Abschnitte gleiten beim ersten Sichtbarwerden herein ----------
// Nur was beim Laden unterhalb des Bildschirms liegt, wird erst versteckt – sichtbarer Inhalt bleibt sichtbar.
if (!reduceMotion.matches && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('reveal-in');
entry.target.classList.remove('reveal-pending');
observer.unobserve(entry.target);
}
}, { rootMargin: '0px 0px -10% 0px' });
for (const el of document.querySelectorAll('.reveal')) {
if (el.getBoundingClientRect().top > innerHeight) {
el.classList.add('reveal-pending');
observer.observe(el);
}
}
}
// ---------- Nächster Einsatz + nächste Termine ----------
function daysUntil(iso) {
return Math.round((fromIso(iso) - fromIso(toIso(new Date()))) / 86400000);
}
function renderNext(events) {
const next = events.find((e) => e.isGame) || events[0];
const title = document.getElementById('next-title');
const when = document.getElementById('next-when');
const box = document.getElementById('next-countdown');
if (!next) {
title.textContent = 'Noch nichts geplant';
when.textContent = 'Der nächste Spieltag steht noch nicht im Kalender.';
return;
}
title.textContent = next.title;
when.textContent = [formatDate(next.date, true), formatTime(next)].filter(Boolean).join(' · ');
const days = daysUntil(next.date);
const label = next.attendeeCount === 1 ? '1 Zusage' : `${next.attendeeCount} Zusagen`;
box.innerHTML = days <= 0
? `<span class="big">Heute</span><span class="unit">${label}</span>`
: `<span class="big">${days}</span>
<span class="unit">${days === 1 ? 'Tag' : 'Tage'}<small>bis zum Spiel · ${label}</small></span>`;
box.hidden = false;
}
const box = document.getElementById('upcoming');
try {
const events = await api('GET', '/upcoming');
renderNext(events);
box.innerHTML = events.length
? `<ul class="list">${events.map((e) => `
<li>
<span class="name"><strong>${esc(e.title)}</strong>
<span class="meta">· ${formatDate(e.date, true)}${e.time ? `, ${e.time} Uhr` : ''}</span></span>
${e.isGame ? '' : '<span class="badge nogame">Kein Spiel</span>'}
<span class="badge">${e.attendeeCount} zugesagt</span>
</li>`).join('')}</ul>`
: '<p class="muted">Noch keine Termine eingetragen.</p>';
} catch {
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
document.getElementById('next-title').textContent = 'Termine nicht erreichbar';
}
// ---------- Pile of Shame: wer dieses Jahr noch nicht spielen war ----------
const shame = document.getElementById('shame');
try {
const { year, entries } = await api('GET', '/stats');
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
shame.innerHTML = names.length
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
: `<p class="muted">Leer, alle waren ${year} schon spielen.</p>`;
} catch {
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
}
// ---------- Patches (neueste zuerst) ----------
try {
const patches = await api('GET', '/patches');
if (patches.length) {
document.getElementById('patch-rail').innerHTML = patches.slice(0, 12).map((p) => `
<a href="/patches#${p.id}">
<figure>
<div class="patch-img"><img src="/patch-img/${encodeURIComponent(p.image)}" alt="" loading="lazy"></div>
<figcaption>${esc(p.name)}${p.status === 'geplant' ? ' <span class="badge nogame">In Planung</span>' : ''}</figcaption>
</figure>
</a>`).join('');
document.getElementById('patch-section').hidden = false;
}
} catch { /* Abschnitt bleibt ausgeblendet */ }
+202
View File
@@ -0,0 +1,202 @@
import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js';
renderHeader('patches');
renderFooter();
let patches = [];
let loggedIn = false;
let editingId = null;
let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes
const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`;
// ---------- Galerie ----------
function render() {
$('#patch-empty').hidden = patches.length > 0;
$('#patch-empty-hint').textContent = loggedIn
? 'Lade über „Patch hochladen“ den ersten Patch hoch.'
: 'Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.';
for (const status of ['vorhanden', 'geplant']) {
const list = patches.filter((p) => p.status === status);
$(`#group-${status}`).hidden = list.length === 0;
$(`#count-${status}`).textContent = list.length === 1 ? '1 Patch' : `${list.length} Patches`;
$(`#grid-${status}`).innerHTML = list.map(patchHtml).join('');
}
}
function patchHtml(p) {
return `
<article class="patch ${p.status === 'geplant' ? 'planned' : ''}" id="${p.id}">
<button type="button" class="patch-img" data-zoom="${p.id}" aria-label="${esc(p.name)} groß anzeigen">
<img src="${imgUrl(p)}" alt="${esc(p.name)}" loading="lazy">
</button>
<div class="patch-body">
${p.status === 'geplant' ? '<span class="badge nogame">In Planung</span>' : ''}
<h3>${esc(p.name)}</h3>
${p.description ? `<p>${esc(p.description)}</p>` : ''}
${loggedIn ? `
<div class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-delete="${p.id}">Löschen</button>
</div>` : ''}
</div>
</article>`;
}
document.addEventListener('click', async (e) => {
if (!e.target.closest('.patch-grid')) return;
const zoom = e.target.closest('[data-zoom]');
if (zoom) return openLightbox(zoom.dataset.zoom);
const edit = e.target.closest('[data-edit]');
if (edit) return openForm(patches.find((p) => p.id === edit.dataset.edit));
const del = e.target.closest('[data-delete]');
if (del) {
const p = patches.find((x) => x.id === del.dataset.delete);
if (!confirm(`Patch „${p.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/patches/${p.id}`);
await load();
} catch (err) {
showError(err);
}
}
});
function openLightbox(id) {
const p = patches.find((x) => x.id === id);
if (!p) return;
$('#lightbox-img').src = imgUrl(p);
$('#lightbox-img').alt = p.name;
$('#lightbox-name').textContent = p.name;
$('#lightbox-text').textContent = p.description;
$('#lightbox').showModal();
}
// Klick neben das Bild schließt
$('#lightbox').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.close();
});
// ---------- Formular ----------
function openForm(patch) {
editingId = patch?.id ?? null;
imageData = null;
$('#patch-form').reset();
$('#patch-error').hidden = true;
$('#patch-form-title').textContent = patch ? 'Patch bearbeiten' : 'Patch hochladen';
$('#patch-submit').textContent = patch ? 'Speichern' : 'Hochladen';
$('#patch-name').value = patch?.name ?? '';
$('#patch-description').value = patch?.description ?? '';
$(`#patch-status-${patch?.status ?? 'vorhanden'}`).checked = true;
showPreview(patch ? imgUrl(patch) : null);
$('#patch-form-card').hidden = false;
$('#patch-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('#patch-name').focus({ preventScroll: true });
}
function closeForm() {
$('#patch-form-card').hidden = true;
editingId = null;
imageData = null;
}
function showPreview(src) {
$('#patch-preview').hidden = !src;
if (src) $('#patch-preview').src = src;
$('#drop-text').innerHTML = src
? '<strong>Anderes Bild wählen</strong>'
: '<strong>Bild auswählen</strong><br>oder hierher ziehen · PNG, JPEG, WebP oder SVG';
}
// Bild im Browser auf höchstens 1600 px verkleinern; Transparenz bleibt (WebP bzw. PNG)
async function prepareImage(file) {
// SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe)
if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) {
if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).');
const bytes = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return `data:image/svg+xml;base64,${btoa(bin)}`;
}
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.');
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close?.();
let url = canvas.toDataURL('image/webp', 0.9);
// Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner
if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88);
return url;
}
async function pickFile(file) {
if (!file) return;
$('#patch-error').hidden = true;
try {
imageData = await prepareImage(file);
showPreview(imageData);
if (!$('#patch-name').value) $('#patch-name').value = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 80);
} catch (err) {
imageData = null;
$('#patch-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.';
$('#patch-error').hidden = false;
}
}
$('#patch-file').addEventListener('change', (e) => pickFile(e.target.files[0]));
const drop = $('#dropzone');
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => {
e.preventDefault();
drop.classList.remove('over');
pickFile(e.dataTransfer.files[0]);
});
$('#add-btn').addEventListener('click', () => openForm(null));
$('#patch-cancel').addEventListener('click', closeForm);
$('#patch-form').addEventListener('submit', async (e) => {
e.preventDefault();
const error = $('#patch-error');
error.hidden = true;
if (!editingId && !imageData) {
error.textContent = 'Bitte zuerst ein Bild auswählen.';
error.hidden = false;
return;
}
const button = $('#patch-submit');
button.disabled = true;
try {
const body = {
name: $('#patch-name').value,
description: $('#patch-description').value,
status: document.querySelector('input[name="patch-status"]:checked').value,
};
if (imageData) body.image = imageData;
if (editingId) await api('PUT', `/patches/${editingId}`, body);
else await api('POST', '/patches', body);
closeForm();
await load();
} catch (err) {
error.textContent = err.message;
error.hidden = false;
} finally {
button.disabled = false;
}
});
// ---------- Laden ----------
async function load() {
patches = await api('GET', '/patches');
render();
}
loggedIn = !!(await currentUser());
$('#add-btn').hidden = !loggedIn;
await load().catch(showError);
// Link von der Startseite (/patches#id) springt zum Patch
if (location.hash) document.getElementById(location.hash.slice(1))?.scrollIntoView({ block: 'center' });
+8 -6
View File
@@ -17,11 +17,14 @@ function render() {
$('#stats-edit-hint').hidden = !canEdit; $('#stats-edit-hint').hidden = !canEdit;
// Gleiche Anzahl → gleicher Platz // Gleiche Anzahl → gleicher Platz
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length; const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
const max = Math.max(1, ...stats.map((s) => s.total));
$('#stats-list').innerHTML = stats.map((s) => ` $('#stats-list').innerHTML = stats.map((s) => `
<li> <li class="${rank(s) <= 3 && s.total > 0 ? 'top' : ''}">
<span class="rank">${rank(s)}.</span> <span class="rank">${rank(s)}.</span>
<span class="name">${esc(s.name)}</span> <span class="who">
${detail(s)} <span class="name">${esc(s.name)} ${detail(s)}</span>
<span class="stat-bar" aria-hidden="true"><i style="width: ${(s.total / max) * 100}%"></i></span>
</span>
<span class="actions"> <span class="actions">
${canEdit ? ` ${canEdit ? `
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''} <button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
@@ -33,7 +36,7 @@ function render() {
} }
$('#stats-list').addEventListener('click', async (e) => { $('#stats-list').addEventListener('click', async (e) => {
const { step, id } = e.target.dataset; const { step, id } = e.target.closest('[data-step]')?.dataset ?? {};
if (!step) return; if (!step) return;
const s = stats.find((x) => x.id === id); const s = stats.find((x) => x.id === id);
try { try {
@@ -56,8 +59,7 @@ async function load(year) {
$('#stats-year').innerHTML = data.years $('#stats-year').innerHTML = data.years
.map((y, i) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).join(''); .map((y, i) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).join('');
$('#stats-info').textContent = data.live $('#stats-info').textContent = data.live
? `Gezählt werden die Spieltage seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat. ` ? `Laufendes Jahr: Spiel-Events seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat.`
+ 'Zu Neujahr wird das Jahr gespeichert und die Zählung beginnt von vorn. Externe werden nicht aufgeführt.'
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`; : `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
render(); render();
} }
+4
View File
@@ -9,7 +9,11 @@
<body> <body>
<main class="login"> <main class="login">
<div class="card"> <div class="card">
<div class="card-head">
<h2>Anmelden</h2> <h2>Anmelden</h2>
<img src="/img/logo.png" alt="" width="48" height="48">
</div>
<p class="muted">Kalender, Fahrtplaner und Gruppe sind nur für Mitglieder.</p>
<form id="login-form"> <form id="login-form">
<label>Benutzername <label>Benutzername
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false"> <input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Patches – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main>
<header class="page-head">
<div>
<span class="eyebrow">Klett &amp; Stoff</span>
<h1>Patches</h1>
<p>Gruppen-Patches, Event-Patches und alles, was bei der HCRB auf die Weste kommt.</p>
</div>
<div class="actions">
<button type="button" id="add-btn" class="primary" hidden>Patch hochladen</button>
</div>
</header>
<div class="card" id="patch-form-card" hidden>
<h2 id="patch-form-title">Patch hochladen</h2>
<form id="patch-form" class="split">
<label class="dropzone" id="dropzone" for="patch-file">
<img id="patch-preview" alt="Vorschau" hidden>
<span id="drop-text">
<strong>Bild auswählen</strong><br>
oder hierher ziehen · PNG, JPEG, WebP oder SVG
</span>
<input type="file" id="patch-file" accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg" hidden>
</label>
<div>
<label>Name
<input type="text" id="patch-name" required maxlength="80" placeholder="z. B. HCRB Gruppenpatch 2026">
</label>
<fieldset class="segmented">
<legend>Status</legend>
<label><input type="radio" name="patch-status" id="patch-status-vorhanden" value="vorhanden" checked> Gibt es schon</label>
<label><input type="radio" name="patch-status" id="patch-status-geplant" value="geplant"> In Planung</label>
</fieldset>
<label>Beschreibung (optional)
<textarea id="patch-description" maxlength="500" placeholder="Wofür, seit wann, wer hat ihn entworfen …"></textarea>
</label>
<p class="muted">Tipp: Ein freigestelltes PNG oder eine SVG ohne Hintergrund sieht in der Galerie am besten aus.
Große Bilder werden vor dem Hochladen automatisch verkleinert.</p>
<p id="patch-error" class="warn" hidden></p>
<div class="row wrap">
<button type="submit" class="primary" id="patch-submit">Hochladen</button>
<button type="button" id="patch-cancel">Abbrechen</button>
</div>
</div>
</form>
</div>
<section class="patch-group" id="group-vorhanden" hidden>
<div class="section-title"><h2>Gibt es schon</h2><span class="muted" id="count-vorhanden"></span></div>
<div class="patch-grid" id="grid-vorhanden"></div>
</section>
<section class="patch-group" id="group-geplant" hidden>
<div class="section-title"><h2>In Planung</h2><span class="muted" id="count-geplant"></span></div>
<div class="patch-grid" id="grid-geplant"></div>
</section>
<div id="patch-empty" class="empty-state" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 2.5l8 3.5v6c0 5-3.5 8.2-8 9.5-4.5-1.3-8-4.5-8-9.5V6z"/></svg>
<strong>Noch keine Patches</strong>
<span id="patch-empty-hint">Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.</span>
</div>
<dialog class="lightbox" id="lightbox">
<form method="dialog"><button class="close small" aria-label="Schließen">✕</button></form>
<figure>
<img id="lightbox-img" alt="">
<figcaption><strong id="lightbox-name"></strong><span id="lightbox-text" class="muted"></span></figcaption>
</figure>
</dialog>
</main>
<script type="module" src="/js/patches.js"></script>
</body>
</html>
+24 -5
View File
@@ -7,18 +7,37 @@
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
</head> </head>
<body> <body>
<main class="narrow"> <main>
<div class="card"> <header class="page-head">
<div class="card-head"> <div>
<h2>Gespielte Spieltage</h2> <span class="eyebrow">Gespielte Spieltage</span>
<h1>Statistik</h1>
<p id="stats-info"></p>
</div>
<div class="actions">
<select id="stats-year" aria-label="Jahr"></select> <select id="stats-year" aria-label="Jahr"></select>
</div> </div>
<p id="stats-info" class="muted"></p> </header>
<div class="split wide-left">
<div class="card">
<h2>Rangliste</h2>
<p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten, <p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten,
die nicht im Kalender standen.</p> die nicht im Kalender standen.</p>
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p> <p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
<ol id="stats-list" class="list stats"></ol> <ol id="stats-list" class="list stats"></ol>
</div> </div>
<div class="card">
<h2>So wird gezählt</h2>
<ul class="facts">
<li>Jeder Termin im Kalender, der als <strong>Spiel-Event</strong> markiert ist und bei dem du zugesagt hast, zählt als ein Spiel, sobald der Tag vorbei oder heute ist.</li>
<li>Termine ohne Spiel (Treffen, Grillen, Basteltage) zählen nicht.</li>
<li>Externe werden nicht aufgeführt.</li>
<li>Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.</li>
</ul>
</div>
</div>
</main> </main>
<script type="module" src="/js/statistik.js"></script> <script type="module" src="/js/statistik.js"></script>
+235 -7
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { authRoutes, requireAuth } from './auth.js'; import { authRoutes, requireAuth } from './auth.js';
@@ -8,23 +8,28 @@ import { authRoutes, requireAuth } from './auth.js';
const ROOT = dirname(fileURLToPath(import.meta.url)); const ROOT = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(ROOT, 'data'); const DATA_DIR = join(ROOT, 'data');
const DB_FILE = join(DATA_DIR, 'db.json'); const DB_FILE = join(DATA_DIR, 'db.json');
const PATCH_DIR = join(DATA_DIR, 'patches');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST; // optional, z. B. 127.0.0.1, damit nur ein Proxy auf demselben Rechner zugreifen kann const HOST = process.env.HOST; // optional, z. B. 127.0.0.1, damit nur ein Proxy auf demselben Rechner zugreifen kann
// ---------- Datenspeicher (JSON-Datei) ---------- // ---------- Datenspeicher (JSON-Datei) ----------
function loadDb() { function loadDb() {
if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [] }; if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [], patches: [] };
const db = JSON.parse(readFileSync(DB_FILE, 'utf8')); const db = JSON.parse(readFileSync(DB_FILE, 'utf8'));
const members = db.members || []; const members = db.members || [];
// Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr // Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr
for (const m of members) { for (const m of members) {
if (typeof m.playsManual === 'number') m.playsManual = { [new Date().getFullYear()]: m.playsManual }; if (typeof m.playsManual === 'number') m.playsManual = { [new Date().getFullYear()]: m.playsManual };
} }
// Ältere Termine haben kein Spiel-Feld – das waren alles Spieltage
const events = (db.events || []).map((e) => ({ isGame: true, ...e }));
return { return {
members, members,
events: db.events || [], events,
meetingPoints: db.meetingPoints || [], meetingPoints: db.meetingPoints || [],
statsArchive: db.statsArchive || [], statsArchive: db.statsArchive || [],
// Ältere Patches haben keinen Status – die gab es schon
patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })),
}; };
} }
@@ -59,7 +64,85 @@ function cleanEvent(body) {
const title = String(body.title ?? '').trim(); const title = String(body.title ?? '').trim();
if (!title) throw new BadRequest('Titel fehlt.'); if (!title) throw new BadRequest('Titel fehlt.');
const note = String(body.note ?? '').trim(); const note = String(body.note ?? '').trim();
return { date, title: title.slice(0, 80), note: note.slice(0, 500) }; const time = cleanTime(body.time);
const endTime = time ? cleanTime(body.endTime) : null;
return { date, title: title.slice(0, 80), note: note.slice(0, 500), isGame: body.isGame !== false, time, endTime };
}
function cleanTime(value) {
const t = String(value ?? '');
return /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null;
}
// Bild als data:-URL; erlaubt sind PNG, JPEG und WebP (geprüft am Dateianfang, nicht nur am Typ)
const IMAGE_TYPES = [
{ ext: 'png', magic: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
{ ext: 'jpg', magic: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
{ ext: 'webp', magic: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
];
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const MAX_SVG_BYTES = 2 * 1024 * 1024;
// SVG ist Text und kann Skripte enthalten. Alles, was Code ausführen oder fremde Dateien nachladen könnte,
// wird abgelehnt; zusätzlich liefert /patch-img SVGs mit einer CSP aus, die Skripte sperrt.
const SVG_FORBIDDEN = [
[/<script/i, 'Skripte'],
[/<foreignObject/i, 'eingebettetes HTML'],
[/<!ENTITY|<!DOCTYPE[^>]*\[/i, 'eigene Entities'],
[/\son[a-z]+\s*=/i, 'Ereignis-Attribute (onload, onclick, …)'],
[/javascript:|vbscript:|data:text\/html/i, 'Skript-Links'],
[/<(iframe|embed|object|audio|video)\b/i, 'eingebettete Inhalte'],
[/(href|src)\s*=\s*["']\s*(https?:)?\/\//i, 'Verweise auf fremde Dateien'],
[/@import|url\(\s*["']?\s*(https?:)?\/\//i, 'nachgeladene Stile oder Bilder'],
];
function isSvg(buf) {
const head = buf.subarray(0, 1024).toString('utf8').replace(/^\uFEFF/, '');
// XML-Kopf, Kommentare und ein einfacher DOCTYPE (Illustrator) dürfen vor <svg> stehen
return /^\s*(<\?xml[^>]*>\s*)?((<!--[\s\S]*?-->|<!DOCTYPE svg[^>\[]*>)\s*)*<svg[\s>]/i.test(head);
}
function checkSvg(buf) {
if (buf.length > MAX_SVG_BYTES) throw new BadRequest('SVG ist zu groß (max. 2 MB).');
const text = buf.toString('utf8');
for (const [re, what] of SVG_FORBIDDEN) {
if (re.test(text)) throw new BadRequest(`SVG enthält ${what} – bitte als einfache Grafik exportieren.`);
}
}
function cleanImage(dataUrl) {
const m = /^data:image\/[a-z+.-]+;base64,([A-Za-z0-9+/=]+)$/.exec(String(dataUrl ?? ''));
if (!m) throw new BadRequest('Bild fehlt oder ist ungültig.');
const buf = Buffer.from(m[1], 'base64');
if (buf.length > MAX_IMAGE_BYTES) throw new BadRequest('Bild ist zu groß (max. 8 MB).');
if (isSvg(buf)) {
checkSvg(buf);
return { buf, ext: 'svg' };
}
const type = IMAGE_TYPES.find((t) => t.magic(buf));
if (!type) throw new BadRequest('Nur PNG-, JPEG-, WebP- oder SVG-Bilder.');
return { buf, ext: type.ext };
}
const PATCH_STATUS = ['vorhanden', 'geplant'];
function cleanPatch(body) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Name des Patches fehlt.');
const description = String(body.description ?? '').trim();
const status = PATCH_STATUS.includes(body.status) ? body.status : 'vorhanden';
return { name: name.slice(0, 80), description: description.slice(0, 500), status };
}
function savePatchImage({ buf, ext }) {
mkdirSync(PATCH_DIR, { recursive: true });
const file = `${randomUUID()}.${ext}`;
writeFileSync(join(PATCH_DIR, file), buf);
return file;
}
function removePatchImage(file) {
try { unlinkSync(join(PATCH_DIR, file)); } catch { /* schon weg */ }
} }
function cleanMeetingPoint(body) { function cleanMeetingPoint(body) {
@@ -97,15 +180,29 @@ function findOr404(list, id, res, what) {
// ---------- App ---------- // ---------- App ----------
const app = express(); const app = express();
app.disable('x-powered-by');
// Hinter nginx: echte Client-IP und HTTPS erkennen. Standard: Proxy auf demselben Rechner. // Hinter nginx: echte Client-IP und HTTPS erkennen. Standard: Proxy auf demselben Rechner.
// Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal" // Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal"
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback'); app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
// Patch-Uploads enthalten ein Bild, alles andere bleibt klein
app.use('/api/patches', express.json({ limit: '12mb' }));
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
// Öffentlich: Startseite, Login, CSS/Bilder // Öffentlich: Startseite, Login, CSS/Bilder
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] })); app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
authRoutes(app); authRoutes(app);
// Patch-Bilder sind öffentlich (Dateinamen sind zufällige IDs)
app.use('/patch-img', express.static(PATCH_DIR, {
maxAge: '30d',
immutable: true,
setHeaders: (res, path) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
// SVG direkt aufgerufen: keine Skripte, keine fremden Inhalte
if (path.endsWith('.svg')) res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
},
}));
// Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen // Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen
function todayIso() { function todayIso() {
const d = new Date(); const d = new Date();
@@ -118,16 +215,17 @@ app.get('/api/upcoming', (req, res) => {
.filter((e) => e.date >= today) .filter((e) => e.date >= today)
.sort((a, b) => a.date.localeCompare(b.date)) .sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 3) .slice(0, 3)
.map((e) => ({ date: e.date, title: e.title, attendeeCount: e.attendees.length }))); .map((e) => ({ date: e.date, title: e.title, time: e.time, isGame: e.isGame, attendeeCount: e.attendees.length })));
}); });
// ---------- Jahresstatistik ---------- // ---------- Jahresstatistik ----------
// Spiele pro Mitglied (ohne Externe) in einem Jahr: Spieltage mit Zusage bis heute + manuelle Korrektur // Spiele pro Mitglied (ohne Externe) in einem Jahr: Spiel-Events mit Zusage bis heute + manuelle Korrektur.
// Termine ohne Spiel (Treffen, Grillen, …) zählen nicht.
function yearStats(year) { function yearStats(year) {
const today = todayIso(); const today = todayIso();
const fromCalendar = new Map(); const fromCalendar = new Map();
for (const ev of db.events) { for (const ev of db.events) {
if (!ev.date.startsWith(`${year}-`) || ev.date > today) continue; if (!ev.isGame || !ev.date.startsWith(`${year}-`) || ev.date > today) continue;
for (const id of ev.attendees) fromCalendar.set(id, (fromCalendar.get(id) || 0) + 1); for (const id of ev.attendees) fromCalendar.set(id, (fromCalendar.get(id) || 0) + 1);
} }
return db.members.filter((m) => !m.external).map((m) => { return db.members.filter((m) => !m.external).map((m) => {
@@ -170,8 +268,47 @@ app.get('/api/stats', (req, res) => {
res.json({ year, live: false, years, savedAt: archived.savedAt, entries: archived.entries }); res.json({ year, live: false, years, savedAt: archived.savedAt, entries: archived.entries });
}); });
// ---------- Patches ----------
const sortedPatches = () => [...db.patches].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
app.get('/api/patches', (req, res) => {
res.json(sortedPatches());
});
// Ab hier nur mit Login // Ab hier nur mit Login
app.use(requireAuth); app.use(requireAuth);
app.post('/api/patches', (req, res) => {
const fields = cleanPatch(req.body);
const image = savePatchImage(cleanImage(req.body.image));
const patch = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
db.patches.push(patch);
saveDb();
res.status(201).json(patch);
});
// Bild ist optional – ohne Bild werden nur Name und Beschreibung geändert
app.put('/api/patches/:id', (req, res) => {
const patch = findOr404(db.patches, req.params.id, res, 'Patch');
if (!patch) return;
const fields = cleanPatch(req.body);
if (req.body.image) {
const old = patch.image;
patch.image = savePatchImage(cleanImage(req.body.image));
removePatchImage(old);
}
Object.assign(patch, fields);
saveDb();
res.json(patch);
});
app.delete('/api/patches/:id', (req, res) => {
const patch = db.patches.find((p) => p.id === req.params.id);
if (patch) removePatchImage(patch.image);
db.patches = db.patches.filter((p) => p.id !== req.params.id);
saveDb();
res.status(204).end();
});
app.use(express.static(join(ROOT, 'private'), { app.use(express.static(join(ROOT, 'private'), {
extensions: ['html'], extensions: ['html'],
setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'), setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'),
@@ -266,6 +403,17 @@ app.get('/api/events', (req, res) => {
res.json([...db.events].sort((a, b) => a.date.localeCompare(b.date))); res.json([...db.events].sort((a, b) => a.date.localeCompare(b.date)));
}); });
// Kalenderdateien (.ics) – alle kommenden Termine oder ein einzelner
app.get('/api/calendar.ics', (req, res) => {
const today = todayIso();
sendIcs(res, 'hcrb-termine.ics', db.events.filter((e) => e.date >= today).sort((a, b) => a.date.localeCompare(b.date)));
});
app.get('/api/events/:id/ics', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (ev) sendIcs(res, `hcrb-${ev.date}.ics`, [ev]);
});
app.get('/api/events/:id', (req, res) => { app.get('/api/events/:id', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin'); const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (ev) res.json(ev); if (ev) res.json(ev);
@@ -316,6 +464,86 @@ app.delete('/api/events/:id/attendees/:memberId', (req, res) => {
res.json(ev); res.json(ev);
}); });
// ---------- iCalendar ----------
function icsText(s) {
return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// Zeilen länger als 75 Byte werden umgebrochen (RFC 5545), ohne UTF-8-Zeichen zu zerteilen
function icsFold(line) {
const out = [];
let cur = '';
let bytes = 0;
for (const ch of line) {
const n = Buffer.byteLength(ch);
if (bytes + n > (out.length ? 74 : 75)) {
out.push(cur);
cur = '';
bytes = 0;
}
cur += ch;
bytes += n;
}
out.push(cur);
return out.join('\r\n ');
}
const compactDate = (iso) => iso.replaceAll('-', '');
const compactTime = (t) => t.replace(':', '') + '00';
function shiftDate(iso, days) {
const [y, m, d] = iso.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
return dt.toISOString().slice(0, 10);
}
const VTIMEZONE_BERLIN = [
'BEGIN:VTIMEZONE', 'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST',
'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 'END:DAYLIGHT',
'BEGIN:STANDARD', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET',
'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'END:STANDARD',
'END:VTIMEZONE',
];
function icsEvent(ev, stamp) {
const names = ev.attendees.map((id) => db.members.find((m) => m.id === id)?.name).filter(Boolean)
.sort((a, b) => a.localeCompare(b, 'de'));
const description = [ev.note, names.length ? `Zusagen (${names.length}): ${names.join(', ')}` : '']
.filter(Boolean).join('\n\n');
const lines = ['BEGIN:VEVENT', `UID:${ev.id}@hcrb`, `DTSTAMP:${stamp}`];
if (ev.time) {
// Ohne Ende: 6 Stunden, höchstens bis Mitternacht
const [h, m] = ev.time.split(':').map(Number);
const end = ev.endTime && ev.endTime > ev.time
? ev.endTime
: `${String(Math.min(23, h + 6)).padStart(2, '0')}:${h + 6 > 23 ? '59' : String(m).padStart(2, '0')}`;
lines.push(`DTSTART;TZID=Europe/Berlin:${compactDate(ev.date)}T${compactTime(ev.time)}`);
lines.push(`DTEND;TZID=Europe/Berlin:${compactDate(ev.date)}T${compactTime(end)}`);
} else {
lines.push(`DTSTART;VALUE=DATE:${compactDate(ev.date)}`);
lines.push(`DTEND;VALUE=DATE:${compactDate(shiftDate(ev.date, 1))}`);
}
lines.push(`SUMMARY:${icsText(ev.title)}`);
if (description) lines.push(`DESCRIPTION:${icsText(description)}`);
lines.push(`CATEGORIES:${ev.isGame ? 'Airsoft,Spiel' : 'HCRB'}`, 'END:VEVENT');
return lines;
}
function sendIcs(res, filename, events) {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//HCRB//Plan-Tool//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
'X-WR-CALNAME:HCRB Termine', 'X-WR-TIMEZONE:Europe/Berlin',
...(events.some((e) => e.time) ? VTIMEZONE_BERLIN : []),
...events.flatMap((ev) => icsEvent(ev, stamp)),
'END:VCALENDAR',
];
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(lines.map(icsFold).join('\r\n') + '\r\n');
}
app.use('/api', (req, res) => { app.use('/api', (req, res) => {
res.status(404).json({ error: 'Unbekannte Anfrage.' }); res.status(404).json({ error: 'Unbekannte Anfrage.' });
}); });