mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
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:
1 parent
43356a120c
commit
486e6f6784
23 files changed
+1702
-332
No files matched your search
@@ -21,8 +21,11 @@ Anderen Port wählen: `PORT=8080 npm start`.
|
||||
|
||||
## Seiten
|
||||
|
||||
- **Start** (`/`) – Startseite mit Platzhaltertext, Links zu Kalender und Fahrtplaner, nächste Spieltage.
|
||||
- **Kalender** (`/kalender`) – Mitglieder wählen „Ich bin …“, tragen Spieltage ein und sagen zu oder ab.
|
||||
- **Start** (`/`) – Startseite mit Flagge, die beim Scrollen heranzoomt, nächstem Spiel samt Countdown,
|
||||
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.
|
||||
- **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.
|
||||
@@ -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,
|
||||
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:
|
||||
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.
|
||||
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).
|
||||
- **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“,
|
||||
Standard-Treffpunkt als Fahrer, „fährt am liebsten mit bei …“) und Treffpunkte (Name, Adresse) verwalten, Export/Import der Mitglieder als JSON.
|
||||
|
||||
## Login
|
||||
|
||||
Öffentlich sind nur die Startseite (inkl. der nächsten 3 Spieltage: Datum, Titel, Anzahl Zusagen) und die Statistik
|
||||
(Namen und Anzahl gespielter Spieltage).
|
||||
Öffentlich sind nur die Startseite (inkl. der nächsten 3 Termine: Datum, Uhrzeit, Titel, Anzahl Zusagen), die Statistik
|
||||
(Namen und Anzahl gespielter Spieltage) und die Patches.
|
||||
Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar.
|
||||
|
||||
Benutzer werden auf dem Server per Kommandozeile verwaltet:
|
||||
@@ -63,11 +71,11 @@ Sicherheit:
|
||||
## 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
|
||||
scripts/user.js Benutzerverwaltung (npm run user)
|
||||
data/ db.json, users.json, sessions.json (automatisch angelegt, nicht in Git)
|
||||
public/ öffentlich: Startseite, Login, CSS, Bilder, common.js
|
||||
data/ db.json, users.json, sessions.json, patches/ (automatisch angelegt, nicht in Git)
|
||||
public/ öffentlich: Startseite, Statistik, Patches, Login, CSS, Bilder, Schriften, common.js
|
||||
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`,
|
||||
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/`.
|
||||
|
||||
## 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
@@ -7,9 +7,19 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<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 class="split">
|
||||
<div class="col">
|
||||
<div class="card no-print">
|
||||
<div class="card-head">
|
||||
<h2>Fahrt</h2>
|
||||
@@ -36,19 +46,6 @@
|
||||
</label>
|
||||
</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">
|
||||
<h2>Weitere Personen (nur diese Fahrt)</h2>
|
||||
<form id="guest-form" class="row wrap">
|
||||
@@ -60,7 +57,6 @@
|
||||
</form>
|
||||
<ul id="guests-list" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card no-print">
|
||||
<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.
|
||||
@@ -73,8 +69,23 @@
|
||||
</form>
|
||||
<ul id="fixed-list" class="list"></ul>
|
||||
</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>
|
||||
<div class="no-print">
|
||||
<label class="inline">
|
||||
|
||||
+23
-8
@@ -7,7 +7,17 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<main>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<span class="eyebrow">Mitglieder & 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">
|
||||
<h2 id="member-form-title">Mitglied hinzufügen</h2>
|
||||
<form id="member-form">
|
||||
@@ -42,13 +52,6 @@
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
<h2 id="point-form-title">Treffpunkt hinzufügen</h2>
|
||||
<form id="point-form">
|
||||
@@ -67,7 +70,14 @@
|
||||
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
|
||||
<ul id="points-list" class="list"></ul>
|
||||
</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">
|
||||
<h2>Daten sichern</h2>
|
||||
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
|
||||
@@ -78,6 +88,11 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/gruppe.js"></script>
|
||||
|
||||
+82
-26
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
$, api, esc, sortByName, showError, renderHeader,
|
||||
toIso, fromIso, formatDate, getMe, setMe,
|
||||
toIso, fromIso, formatDate, formatTime, getMe, setMe,
|
||||
} from './common.js';
|
||||
|
||||
renderHeader('kalender');
|
||||
@@ -8,6 +8,7 @@ renderHeader('kalender');
|
||||
let members = [];
|
||||
let events = [];
|
||||
let me = getMe();
|
||||
let editingId = null; // Termin, der gerade im Formular bearbeitet wird
|
||||
|
||||
const todayIso = toIso(new Date());
|
||||
let selectedDay = todayIso;
|
||||
@@ -23,7 +24,7 @@ function renderMe() {
|
||||
sortByName(members).map((m) =>
|
||||
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
|
||||
$('#me-hint').hidden = !!me;
|
||||
$('#event-form button[type=submit]').disabled = !me;
|
||||
$('#event-submit').disabled = !me && !editingId;
|
||||
}
|
||||
|
||||
$('#me-select').addEventListener('change', (e) => {
|
||||
@@ -53,14 +54,15 @@ function renderCalendar() {
|
||||
const iso = toIso(day);
|
||||
const classes = ['cal-day'];
|
||||
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 === selectedDay) classes.push('selected');
|
||||
const dayEvents = byDate.get(iso) || [];
|
||||
html += `
|
||||
<button class="${classes.join(' ')}" data-day="${iso}">
|
||||
<span class="num">${day.getDate()}</span>
|
||||
${dayEvents.map((ev) => `<span class="ev ${ev.attendees.includes(me) ? 'mine' : ''}"
|
||||
title="${esc(ev.title)} (${ev.attendees.length} zugesagt)">${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
|
||||
${dayEvents.map((ev) => `<span class="ev ${ev.isGame ? '' : 'nogame'} ${ev.attendees.includes(me) ? 'mine' : ''}"
|
||||
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>`;
|
||||
day.setDate(day.getDate() + 1);
|
||||
}
|
||||
@@ -70,34 +72,43 @@ function renderCalendar() {
|
||||
$('#calendar').addEventListener('click', (e) => {
|
||||
const cell = e.target.closest('[data-day]');
|
||||
if (!cell) return;
|
||||
selectedDay = cell.dataset.day;
|
||||
const d = fromIso(selectedDay);
|
||||
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
renderAll();
|
||||
$('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
selectDay(cell.dataset.day);
|
||||
// Auf schmalen Bildschirmen stehen die Details unter dem Kalender
|
||||
if (matchMedia('(max-width: 999px)').matches) $('#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) {
|
||||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
|
||||
renderCalendar();
|
||||
}
|
||||
$('#prev-month').addEventListener('click', () => shiftMonth(-1));
|
||||
$('#next-month').addEventListener('click', () => shiftMonth(1));
|
||||
$('#today-btn').addEventListener('click', () => {
|
||||
selectedDay = todayIso;
|
||||
viewMonth = fromIso(todayIso);
|
||||
viewMonth.setDate(1);
|
||||
renderAll();
|
||||
});
|
||||
$('#today-btn').addEventListener('click', () => selectDay(todayIso));
|
||||
|
||||
// ---------- 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) {
|
||||
const going = ev.attendees.includes(me);
|
||||
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
|
||||
const when = [formatDate(ev.date, true), formatTime(ev)].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="event">
|
||||
<div class="event ${ev.isGame ? '' : 'nogame'}">
|
||||
<div class="event-top">
|
||||
<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>` : ''}
|
||||
<div class="muted">${ev.attendees.length} zugesagt:</div>
|
||||
<div class="chips">
|
||||
@@ -110,6 +121,8 @@ function eventHtml(ev) {
|
||||
? `<button data-action="leave" data-id="${ev.id}">Absagen</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="/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>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -122,14 +135,14 @@ function renderDay() {
|
||||
const dayEvents = events.filter((e) => e.date === selectedDay);
|
||||
$('#day-events').innerHTML = dayEvents.length
|
||||
? 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() {
|
||||
const upcoming = events.filter((e) => e.date >= todayIso);
|
||||
$('#upcoming').innerHTML = upcoming.length
|
||||
? upcoming.map(eventHtml).join('')
|
||||
: '<p class="muted">Keine kommenden Spieltage.</p>';
|
||||
: '<p class="muted">Keine kommenden Termine.</p>';
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
@@ -137,12 +150,14 @@ document.addEventListener('click', async (e) => {
|
||||
if (!btn) return;
|
||||
const { action, id } = btn.dataset;
|
||||
try {
|
||||
if (action === 'edit') return startEdit(id);
|
||||
if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`);
|
||||
if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`);
|
||||
if (action === 'delete') {
|
||||
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}`);
|
||||
if (editingId === id) stopEdit();
|
||||
}
|
||||
await load();
|
||||
} 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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('POST', '/events', {
|
||||
date: selectedDay,
|
||||
const body = {
|
||||
date: $('#event-date').value,
|
||||
title: $('#event-title').value,
|
||||
note: $('#event-note').value,
|
||||
createdBy: me,
|
||||
});
|
||||
e.target.reset();
|
||||
isGame: $('#event-game').checked,
|
||||
time: $('#event-time').value,
|
||||
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();
|
||||
if (date && date !== selectedDay) selectDay(date);
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
@@ -179,4 +234,5 @@ async function load() {
|
||||
renderAll();
|
||||
}
|
||||
|
||||
$('#event-date').value = selectedDay;
|
||||
load().catch(showError);
|
||||
+64
-14
@@ -8,42 +8,92 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<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 Spieltage eintragen und zusagen kannst.
|
||||
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<span class="eyebrow">Spieltage & Termine</span>
|
||||
<h1>Kalender</h1>
|
||||
<p>Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.</p>
|
||||
</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="cal-nav">
|
||||
<button id="prev-month" aria-label="Vorheriger Monat">‹</button>
|
||||
<h2 id="month-title"></h2>
|
||||
<button id="next-month" aria-label="Nächster Monat">›</button>
|
||||
<button id="today-btn" class="small">Heute</button>
|
||||
</div>
|
||||
<div class="row end"><button id="today-btn" class="small">Heute</button></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 class="card">
|
||||
<h2 id="day-title"></h2>
|
||||
<div id="day-events"></div>
|
||||
<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
|
||||
<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 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)
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Kommende Spieltage</h2>
|
||||
<div id="upcoming"></div>
|
||||
<section class="span-all">
|
||||
<div class="section-title">
|
||||
<h2>Kommende Termine</h2>
|
||||
</div>
|
||||
<div id="upcoming" class="events-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
+709
-179
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.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
+73
-59
@@ -4,50 +4,93 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<body class="home">
|
||||
<section class="hero" aria-label="HCRB">
|
||||
<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>
|
||||
<div class="subtitle">Highcap Rasselbande</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<p class="intro">
|
||||
Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin.
|
||||
Am häufigsten findet ihr uns auf der Darkzone.
|
||||
</p>
|
||||
<section class="next-op reveal" id="next-op">
|
||||
<div>
|
||||
<span class="eyebrow">Nächster Einsatz</span>
|
||||
<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">
|
||||
<strong>Diese Seite ist noch in Arbeit.</strong> Es kommen noch Änderungen und neue Funktionen dazu.
|
||||
</p>
|
||||
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/kalender">
|
||||
<nav class="areas reveal" aria-label="Bereiche">
|
||||
<a class="tile big" href="/kalender">
|
||||
<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>
|
||||
<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"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>Kalender</h2>
|
||||
<p>Spieltage eintragen und zusagen.</p>
|
||||
</div>
|
||||
<p>Spieltage und Termine eintragen, zusagen und als Kalenderdatei aufs Handy holen.</p>
|
||||
<span class="go">Zum Kalender</span>
|
||||
</a>
|
||||
<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">
|
||||
<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>
|
||||
<div>
|
||||
<h2>Fahrtplaner</h2>
|
||||
<p>Fahrgemeinschaften einteilen.</p>
|
||||
</div>
|
||||
</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-head">
|
||||
<h2>Nächste Spieltage</h2>
|
||||
<h2>Nächste Termine</h2>
|
||||
<a href="/kalender" class="button small">Zum Kalender</a>
|
||||
</div>
|
||||
<div id="upcoming"><p class="muted">Lädt …</p></div>
|
||||
@@ -60,16 +103,20 @@
|
||||
</div>
|
||||
<div id="shame"><p class="muted">Lädt …</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Über uns</h2>
|
||||
<section class="about reveal">
|
||||
<img src="/img/logo.png" alt="" width="128" height="128">
|
||||
<div>
|
||||
<span class="eyebrow">Über uns</span>
|
||||
<h2>Highcap Rasselbande</h2>
|
||||
<p>
|
||||
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>
|
||||
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>
|
||||
<div class="social">
|
||||
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
|
||||
@@ -80,42 +127,9 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<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>
|
||||
<script type="module" src="/js/home.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+24
-4
@@ -80,11 +80,13 @@ const NAV = [
|
||||
{ href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' },
|
||||
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
|
||||
{ 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');
|
||||
header.className = 'site-header';
|
||||
header.className = overlay ? 'site-header overlay' : 'site-header';
|
||||
header.innerHTML = `
|
||||
<div class="inner">
|
||||
<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>
|
||||
</div>`;
|
||||
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
|
||||
const toggle = header.querySelector('.nav-toggle');
|
||||
toggle.addEventListener('click', () => {
|
||||
@@ -114,7 +121,7 @@ export function renderHeader(active) {
|
||||
export function renderFooter() {
|
||||
const footer = document.createElement('footer');
|
||||
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">
|
||||
<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>
|
||||
@@ -122,8 +129,15 @@ export function renderFooter() {
|
||||
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) {
|
||||
const { user } = await api('GET', '/me').catch(() => ({}));
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
|
||||
return;
|
||||
@@ -138,3 +152,9 @@ async function renderAuthLink(nav) {
|
||||
});
|
||||
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`;
|
||||
}
|
||||
@@ -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 */ }
|
||||
@@ -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' });
|
||||
@@ -17,11 +17,14 @@ function render() {
|
||||
$('#stats-edit-hint').hidden = !canEdit;
|
||||
// Gleiche Anzahl → gleicher Platz
|
||||
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) => `
|
||||
<li>
|
||||
<li class="${rank(s) <= 3 && s.total > 0 ? 'top' : ''}">
|
||||
<span class="rank">${rank(s)}.</span>
|
||||
<span class="name">${esc(s.name)}</span>
|
||||
${detail(s)}
|
||||
<span class="who">
|
||||
<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">
|
||||
${canEdit ? `
|
||||
<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) => {
|
||||
const { step, id } = e.target.dataset;
|
||||
const { step, id } = e.target.closest('[data-step]')?.dataset ?? {};
|
||||
if (!step) return;
|
||||
const s = stats.find((x) => x.id === id);
|
||||
try {
|
||||
@@ -56,8 +59,7 @@ async function load(year) {
|
||||
$('#stats-year').innerHTML = data.years
|
||||
.map((y, i) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).join('');
|
||||
$('#stats-info').textContent = data.live
|
||||
? `Gezählt werden die Spieltage 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.'
|
||||
? `Laufendes Jahr: Spiel-Events seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat.`
|
||||
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
<body>
|
||||
<main class="login">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<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">
|
||||
<label>Benutzername
|
||||
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
|
||||
|
||||
@@ -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 & 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
@@ -7,18 +7,37 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Gespielte Spieltage</h2>
|
||||
<main>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<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>
|
||||
</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,
|
||||
die nicht im Kalender standen.</p>
|
||||
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
|
||||
<ol id="stats-list" class="list stats"></ol>
|
||||
</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>
|
||||
|
||||
<script type="module" src="/js/statistik.js"></script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
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 { fileURLToPath } from 'node:url';
|
||||
import { authRoutes, requireAuth } from './auth.js';
|
||||
@@ -8,23 +8,28 @@ import { authRoutes, requireAuth } from './auth.js';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(ROOT, 'data');
|
||||
const DB_FILE = join(DATA_DIR, 'db.json');
|
||||
const PATCH_DIR = join(DATA_DIR, 'patches');
|
||||
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
|
||||
|
||||
// ---------- Datenspeicher (JSON-Datei) ----------
|
||||
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 members = db.members || [];
|
||||
// Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr
|
||||
for (const m of members) {
|
||||
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 {
|
||||
members,
|
||||
events: db.events || [],
|
||||
events,
|
||||
meetingPoints: db.meetingPoints || [],
|
||||
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();
|
||||
if (!title) throw new BadRequest('Titel fehlt.');
|
||||
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) {
|
||||
@@ -97,15 +180,29 @@ function findOr404(list, id, res, what) {
|
||||
|
||||
// ---------- App ----------
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
// 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"
|
||||
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' }));
|
||||
|
||||
// Öffentlich: Startseite, Login, CSS/Bilder
|
||||
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
|
||||
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
|
||||
function todayIso() {
|
||||
const d = new Date();
|
||||
@@ -118,16 +215,17 @@ app.get('/api/upcoming', (req, res) => {
|
||||
.filter((e) => e.date >= today)
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.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 ----------
|
||||
// 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) {
|
||||
const today = todayIso();
|
||||
const fromCalendar = new Map();
|
||||
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);
|
||||
}
|
||||
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 });
|
||||
});
|
||||
|
||||
// ---------- 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
|
||||
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'), {
|
||||
extensions: ['html'],
|
||||
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)));
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
const ev = findOr404(db.events, req.params.id, res, 'Termin');
|
||||
if (ev) res.json(ev);
|
||||
@@ -316,6 +464,86 @@ app.delete('/api/events/:id/attendees/:memberId', (req, res) => {
|
||||
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) => {
|
||||
res.status(404).json({ error: 'Unbekannte Anfrage.' });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user