Erste Version: HCRB Spieltag-Kalender und Fahrtplaner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-10 13:54:33 +02:00
commit 10400eee59
30 files changed
+3771

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules
data
.git
.vscode
deploy
DEPLOY.md
Dockerfile
docker-compose.yml
*.log
.env
+3
View File
@@ -0,0 +1,3 @@
# Nach .env kopieren und den Netzwerknamen des Nginx Proxy Managers eintragen
# (herausfinden mit: docker inspect <npm-container> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}')
PROXY_NETWORK=npm_default
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/
.env
+193
View File
@@ -0,0 +1,193 @@
# HCRB auf dem eigenen Server betreiben (Docker + Nginx Proxy Manager)
Annahme: ein Linux-Server mit SSH-Zugang und Docker, auf dem der **Nginx Proxy Manager** (NPM) als Container läuft
und die Domains verwaltet. Dazu eine (Sub-)Domain, z. B. `hcrb.example.de`.
Der Ablauf: Code liegt in einem Git-Repository → Server holt ihn per `git pull` → `docker compose` baut und startet
den Container → der Container hängt im selben Docker-Netz wie NPM → NPM leitet die Domain per HTTPS an `hcrb:3000`.
Die Daten (`data/`) liegen außerhalb des Containers auf dem Server und bleiben bei Updates erhalten.
## 1. Lokal: Git-Repository anlegen (einmalig)
```bash
cd ~/Dokumente/Projekte/airsoft_plan_tool
git init -b main
git add .
git commit -m "Erste Version"
```
Dann ein **privates** Repository bei GitHub/GitLab/Codeberg anlegen und hochladen:
```bash
git remote add origin git@github.com:DEIN-NAME/hcrb.git
git push -u origin main
```
`data/`, `.env` und `node_modules/` stehen in `.gitignore` – Passwörter und Mitgliederdaten landen nicht im Repository.
## 2. Domain
Beim Domain-Anbieter einen **A-Record** (und ggf. AAAA für IPv6) für `hcrb.example.de` auf die IP des Servers setzen
(wie bei den anderen Domains, die schon über NPM laufen).
## 3. Code auf den Server holen (einmalig)
Per SSH auf dem Server. Zugriff auf das private Repository – einen SSH-Schlüssel erzeugen:
```bash
ssh-keygen -t ed25519 -N "" -f ~/.ssh/hcrb_deploy
cat ~/.ssh/hcrb_deploy.pub
```
Den ausgegebenen Schlüssel im Repository unter *Settings → Deploy keys* eintragen (nur Lesezugriff). Dann:
```bash
sudo mkdir -p /opt/hcrb && sudo chown $USER: /opt/hcrb
GIT_SSH_COMMAND="ssh -i ~/.ssh/hcrb_deploy" git clone git@github.com:DEIN-NAME/hcrb.git /opt/hcrb
cd /opt/hcrb
git config core.sshCommand "ssh -i ~/.ssh/hcrb_deploy"
chmod +x deploy/*.sh
# Datenordner – der Container läuft als Benutzer "node" (UID 1000) und muss hier schreiben dürfen
mkdir -p data && sudo chown 1000:1000 data
```
## 4. Mit dem Netz des Nginx Proxy Managers verbinden
Der HCRB-Container wird in das Docker-Netz gehängt, in dem NPM schon läuft. Am NPM selbst muss nichts geändert werden.
Name des NPM-Containers und seines Netzes herausfinden:
```bash
docker ps --format '{{.Names}}\t{{.Image}}' | grep -i nginx-proxy-manager
docker inspect NAME-DES-NPM-CONTAINERS --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
```
Die zweite Zeile gibt den Netzwerknamen aus, z. B. `npm_default`. Diesen in die Datei `.env` eintragen:
```bash
cp .env.example .env
nano .env # PROXY_NETWORK=npm_default (durch den ausgegebenen Namen ersetzen)
```
## 5. Daten übernehmen (optional)
Wer die bisherigen Mitglieder, Termine und Logins mitnehmen will, kopiert sie vom eigenen Rechner:
```bash
# lokal ausführen
scp data/db.json data/users.json DEIN-USER@SERVER:/tmp/
# auf dem Server
sudo mv /tmp/db.json /tmp/users.json /opt/hcrb/data/
sudo chown 1000:1000 /opt/hcrb/data/*.json && sudo chmod 600 /opt/hcrb/data/*.json
```
## 6. Container starten
```bash
cd /opt/hcrb
docker compose up -d --build
docker compose ps # Status sollte nach ca. 30 Sekunden "Up … (healthy)" zeigen
```
Ohne übernommene Daten jetzt den ersten Login anlegen (Passwort wird verdeckt abgefragt):
```bash
docker compose exec hcrb npm run user -- add elias
```
Prüfen, ob NPM den Container erreicht (sollte `{"user":null}` ausgeben):
```bash
docker exec NAME-DES-NPM-CONTAINERS curl -s http://hcrb:3000/api/me
```
Der Container startet automatisch neu – nach Abstürzen und nach einem Server-Neustart (`restart: unless-stopped`).
## 7. Domain im Nginx Proxy Manager anlegen
In der NPM-Weboberfläche: **Hosts → Proxy Hosts → Add Proxy Host**
Reiter **Details**
| Feld | Wert |
| --- | --- |
| Domain Names | `hcrb.example.de` |
| Scheme | `http` |
| Forward Hostname / IP | `hcrb` |
| Forward Port | `3000` |
| Cache Assets | aus |
| Block Common Exploits | an |
| Websockets Support | aus (wird nicht gebraucht) |
Reiter **SSL**
| Feld | Wert |
| --- | --- |
| SSL Certificate | Request a new SSL Certificate |
| Force SSL | an |
| HTTP/2 Support | an |
| HSTS Enabled | an |
| E-Mail / Agree to Terms | ausfüllen bzw. anhaken |
Speichern – NPM holt das Zertifikat und verlängert es automatisch. Danach ist die Seite unter
`https://hcrb.example.de` erreichbar.
NPM gibt die echte Besucher-IP und das Protokoll (`X-Forwarded-For`, `X-Forwarded-Proto`) von sich aus weiter –
das braucht die Seite für die Sperre nach 10 falschen Passwörtern und für das sichere Login-Cookie.
Unter *Advanced* muss dafür nichts eingetragen werden.
**Hinweis:** Port 3000 wird bewusst **nicht** nach außen freigegeben – die Seite ist nur über NPM erreichbar.
Die NPM-Oberfläche (Port 81) sollte ebenfalls nicht offen im Internet stehen.
## 8. Backups
```bash
sudo crontab -e
# Zeile hinzufügen:
30 3 * * * /opt/hcrb/deploy/backup.sh
```
Sichert täglich um 3:30 Uhr `data/` nach `/var/backups/hcrb/` und behält 30 Tage.
Am besten zusätzlich ab und zu eine Sicherung vom Server herunterladen.
---
## Änderungen aufspielen (jedes Mal)
```bash
# lokal
git add .
git commit -m "Was geändert wurde"
git push
# auf dem Server
ssh DEIN-USER@SERVER /opt/hcrb/deploy/update-docker.sh
```
`update-docker.sh` holt den neuen Stand, baut den Container neu und startet ihn. Die Daten bleiben unverändert,
an NPM muss nichts geändert werden.
## Nützliche Befehle auf dem Server
Alle im Ordner `/opt/hcrb` ausführen:
| Zweck | Befehl |
| --- | --- |
| Status | `docker compose ps` |
| Log ansehen | `docker compose logs -f` |
| Neu starten | `docker compose restart` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
| Benutzer anlegen | `docker compose exec hcrb npm run user -- add <name>` |
| Passwort ändern | `docker compose exec hcrb npm run user -- passwd <name>` |
| Benutzer auflisten | `docker compose exec hcrb npm run user -- list` |
| Backup zurückspielen | `docker compose down && sudo tar -xzf /var/backups/hcrb/hcrb-DATUM.tar.gz -C /opt/hcrb && sudo chown -R 1000:1000 data && docker compose up -d` |
## Fehlersuche
| Problem | Ursache / Lösung |
| --- | --- |
| `network … declared as external, but could not be found` | Netzwerkname in `.env` stimmt nicht – Schritt 4 wiederholen. |
| NPM zeigt „502 Bad Gateway“ | Container läuft nicht (`docker compose ps`, `docker compose logs`) oder hängt nicht im NPM-Netz (Schritt 4). |
| Container startet neu mit `EACCES … data` | Rechte am Datenordner: `sudo chown -R 1000:1000 /opt/hcrb/data` |
| Nach 10 falschen Passwörtern ist der Login für **alle** gesperrt | Die Seite erkennt die echte Besucher-IP nicht – prüfen, ob `TRUST_PROXY` in `docker-compose.yml` gesetzt ist und der Zugriff über NPM erfolgt. |
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine
ENV NODE_ENV=production \
TZ=Europe/Berlin
WORKDIR /app
# Erst nur die Paketlisten kopieren, damit npm ci bei Code-Änderungen aus dem Cache kommt
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
RUN mkdir -p data && chown node:node data
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/me').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["node", "server.js"]
+78
View File
@@ -0,0 +1,78 @@
# HCRB – Highcap Rasselbande
Webseite der Airsoft-Gruppe HCRB mit Spieltag-Kalender und Fahrtplaner.
## Starten
```bash
npm install # nur beim ersten Mal
npm start # oder: npm run dev (startet bei Änderungen neu)
```
Vor dem ersten Login einen Benutzer anlegen (Passwort wird verdeckt abgefragt):
```bash
npm run user -- add elias
```
Dann im Browser `http://localhost:3000` öffnen.
Andere Geräte im selben WLAN erreichen die Seite über `http://<IP-dieses-Rechners>:3000`.
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.
Ü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.
Wer gern bei jemandem mitfährt, kommt in dessen Auto (oder ins selbe Auto, falls beide mitfahren).
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.
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).
- **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).
Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar.
Benutzer werden auf dem Server per Kommandozeile verwaltet:
```bash
npm run user -- list # alle Benutzer anzeigen
npm run user -- add <name> # Benutzer anlegen
npm run user -- passwd <name> # Passwort ändern (meldet den Benutzer überall ab)
npm run user -- remove <name> # Benutzer löschen (sofort abgemeldet)
```
Sicherheit:
- Passwörter werden nur als scrypt-Hash mit eigenem Salt gespeichert (`data/users.json`), nie im Klartext.
- Sitzungen gelten 30 Tage. Im Browser liegt ein zufälliges Token als `HttpOnly`-Cookie,
auf dem Server nur dessen SHA-256-Hash (`data/sessions.json`).
- Nach 10 Fehlversuchen innerhalb von 15 Minuten wird die IP für den Login gesperrt.
- Im Internet nur über HTTPS betreiben (z. B. hinter nginx), dann wird das Cookie automatisch als `Secure` gesetzt.
## Aufbau
```
server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points)
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
private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe
```
## Auf einem Server betreiben
Per Docker hinter dem Nginx Proxy Manager: `docker compose up -d --build` (Daten liegen in `./data`,
das Docker-Netz des Proxys steht 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/`.
+184
View File
@@ -0,0 +1,184 @@
// Login: Benutzer, Passwort-Hashes und Sitzungen
//
// Passwörter werden nie im Klartext gespeichert, sondern mit scrypt (eigenes Salt pro Passwort) gehasht.
// Sitzungs-Tokens liegen nur im Cookie des Browsers; auf dem Server wird lediglich ihr SHA-256-Hash gespeichert.
import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), 'data');
export const USERS_FILE = join(DATA_DIR, 'users.json');
const SESSIONS_FILE = join(DATA_DIR, 'sessions.json');
const COOKIE = 'hcrb_session';
const SESSION_DAYS = 30;
const MAX_FAILS = 10; // Fehlversuche pro IP …
const FAIL_WINDOW = 15 * 60 * 1000; // … innerhalb von 15 Minuten
export const MIN_PASSWORD = 8;
// ---------- Passwort-Hashing (scrypt) ----------
const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 };
function scryptAsync(password, salt, { N, r, p, keylen }) {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, { N, r, p, maxmem: 128 * N * r * 2 }, (err, key) =>
err ? reject(err) : resolve(key));
});
}
export async function hashPassword(password) {
const salt = randomBytes(16);
const key = await scryptAsync(password, salt, SCRYPT);
const { N, r, p } = SCRYPT;
return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${key.toString('base64')}`;
}
async function verifyPassword(password, stored) {
const [algo, N, r, p, salt, hash] = String(stored).split('$');
if (algo !== 'scrypt') return false;
const expected = Buffer.from(hash, 'base64');
const key = await scryptAsync(password, Buffer.from(salt, 'base64'),
{ N: +N, r: +r, p: +p, keylen: expected.length });
return timingSafeEqual(key, expected);
}
// Für unbekannte Benutzernamen wird trotzdem gehasht, damit man sie nicht an der Antwortzeit erkennt
let dummyHash;
// ---------- Benutzer (data/users.json, wird von scripts/user.js gepflegt) ----------
export function normalizeName(name) {
return String(name ?? '').trim().toLowerCase();
}
function writeJson(file, data) {
mkdirSync(DATA_DIR, { recursive: true });
const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
renameSync(tmp, file);
}
export function readUsers() {
if (!existsSync(USERS_FILE)) return [];
return JSON.parse(readFileSync(USERS_FILE, 'utf8')).users || [];
}
export function writeUsers(users) {
writeJson(USERS_FILE, { users });
}
// Der Server liest die Datei neu ein, sobald das Skript sie geändert hat
let usersCache = { mtime: -1, users: [] };
function currentUsers() {
const mtime = existsSync(USERS_FILE) ? statSync(USERS_FILE).mtimeMs : 0;
if (mtime !== usersCache.mtime) usersCache = { mtime, users: readUsers() };
return usersCache.users;
}
// ---------- Sitzungen (data/sessions.json) ----------
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
const sessions = new Map(
existsSync(SESSIONS_FILE)
? JSON.parse(readFileSync(SESSIONS_FILE, 'utf8')).map((s) => [s.hash, s])
: [],
);
function saveSessions() {
const now = Date.now();
for (const [hash, s] of sessions) if (s.expires < now) sessions.delete(hash);
writeJson(SESSIONS_FILE, [...sessions.values()]);
}
function readCookie(req, name) {
for (const part of (req.headers.cookie || '').split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === name) return decodeURIComponent(v.join('='));
}
return null;
}
function setCookie(req, res, value, maxAgeSec) {
const attrs = [`${COOKIE}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAgeSec}`];
if (req.secure) attrs.push('Secure');
res.setHeader('Set-Cookie', attrs.join('; '));
}
// Liefert den angemeldeten Benutzernamen oder null
function sessionUser(req) {
const token = readCookie(req, COOKIE);
if (!token) return null;
const s = sessions.get(sha256(token));
if (!s || s.expires < Date.now()) return null;
// Benutzer gelöscht oder Passwort seit der Anmeldung geändert → Sitzung ungültig
const user = currentUsers().find((u) => u.name === s.user);
if (!user || s.created < (user.changedAt || 0)) return null;
return user.name;
}
// ---------- Schutz vor Passwort-Raten ----------
const fails = new Map();
function tooManyFails(ip) {
const f = fails.get(ip);
if (f && Date.now() - f.first > FAIL_WINDOW) fails.delete(ip);
return (fails.get(ip)?.count || 0) >= MAX_FAILS;
}
function recordFail(ip) {
const f = fails.get(ip) || { count: 0, first: Date.now() };
f.count++;
fails.set(ip, f);
}
// ---------- Express ----------
export function authRoutes(app) {
app.post('/api/login', async (req, res, next) => {
try {
if (tooManyFails(req.ip)) {
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' });
}
const name = normalizeName(req.body?.username);
const password = String(req.body?.password ?? '');
const user = currentUsers().find((u) => u.name === name);
dummyHash ??= await hashPassword(randomBytes(16).toString('hex'));
const ok = await verifyPassword(password, user ? user.hash : dummyHash);
if (!user || !ok) {
recordFail(req.ip);
return res.status(401).json({ error: 'Benutzername oder Passwort falsch.' });
}
fails.delete(req.ip);
const token = randomBytes(32).toString('base64url');
const now = Date.now();
sessions.set(sha256(token), {
hash: sha256(token), user: user.name, created: now, expires: now + SESSION_DAYS * 86400e3,
});
saveSessions();
setCookie(req, res, token, SESSION_DAYS * 86400);
res.json({ user: user.name });
} catch (err) {
next(err);
}
});
app.post('/api/logout', (req, res) => {
const token = readCookie(req, COOKIE);
if (token && sessions.delete(sha256(token))) saveSessions();
setCookie(req, res, '', 0);
res.status(204).end();
});
app.get('/api/me', (req, res) => {
res.json({ user: sessionUser(req) });
});
}
// Alles, was nach dieser Middleware kommt, ist nur angemeldet erreichbar
export function requireAuth(req, res, next) {
req.user = sessionUser(req);
if (req.user) return next();
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Bitte anmelden.' });
if (req.method === 'GET') return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
res.status(401).end();
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Tägliche Sicherung von data/ (Mitglieder, Termine, Benutzer) – behält die letzten 30 Tage.
# Als Cronjob einrichten (sudo crontab -e):
# 30 3 * * * /opt/hcrb/deploy/backup.sh
set -euo pipefail
DEST=/var/backups/hcrb
mkdir -p "$DEST"
tar -czf "$DEST/hcrb-$(date +%F).tar.gz" -C /opt/hcrb data
chmod 600 "$DEST"/hcrb-*.tar.gz
find "$DEST" -name 'hcrb-*.tar.gz' -mtime +30 -delete
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Neue Version einspielen (Docker) – auf dem Server ausführen: /opt/hcrb/deploy/update-docker.sh
# Die Daten in data/ bleiben dabei unberührt.
set -euo pipefail
cd /opt/hcrb
git pull --ff-only
docker compose up -d --build
docker image prune -f >/dev/null
sleep 3
docker compose ps
+22
View File
@@ -0,0 +1,22 @@
services:
hcrb:
build: .
image: hcrb
container_name: hcrb
restart: unless-stopped
# Kein Port nach außen – der Nginx Proxy Manager erreicht den Container über das gemeinsame Netz als hcrb:3000
environment:
TZ: Europe/Berlin
# Anfragen kommen vom Proxy-Container aus dem Docker-Netz (172.x.x.x)
TRUST_PROXY: "loopback, uniquelocal"
volumes:
# Mitglieder, Termine, Logins – bleiben bei Updates und Neubau des Containers erhalten
- ./data:/app/data
networks:
- proxy
networks:
proxy:
# Netz des Nginx Proxy Managers – Name steht in der Datei .env (PROXY_NETWORK=...)
external: true
name: ${PROXY_NETWORK:-proxy}
+789
View File
@@ -0,0 +1,789 @@
{
"name": "hcrb-plan-tool",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hcrb-plan-tool",
"version": "1.0.0",
"dependencies": {
"express": "^5.3.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.3.0",
"content-disposition": "^2.0.1",
"content-type": "^2.0.0",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.8",
"qs": "^6.16.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.1.0",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"dependencies": {
"content-type": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hcrb-plan-tool",
"version": "1.0.0",
"description": "HCRB (Highcap Rasselbande) – Spieltag-Kalender und Fahrtplaner",
"private": true,
"type": "module",
"main": "server.js",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"user": "node scripts/user.js"
},
"dependencies": {
"express": "^5.3.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fahrtplaner – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div id="from-event" class="notice no-print" hidden></div>
<div class="card no-print">
<div class="card-head">
<h2>Fahrt</h2>
<button type="button" id="reset-btn" class="small">Eingabe zurücksetzen</button>
</div>
<div class="grid2">
<label>Ziel / Event
<input type="text" id="trip-title" placeholder="z. B. Spieltag Waldfeld">
</label>
<label>Datum
<input type="date" id="trip-date">
</label>
</div>
<label>Treffpunkt (Standard)
<select id="trip-point"></select>
</label>
<p id="trip-points-empty" class="muted" hidden>Treffpunkte legst du unter <a href="/gruppe">Gruppe</a> an.</p>
<div class="grid2">
<label>Abfahrt
<input type="time" id="trip-departure" value="07:00" required>
</label>
<label>Treffzeit
<input type="time" id="trip-meet" value="06:45" required>
</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">
<input type="text" id="guest-name" placeholder="Vorname" required autocomplete="off">
<label class="inline"><input type="checkbox" id="guest-hascar"> Hat Auto</label>
<input type="number" id="guest-seats" min="1" max="8" value="4" title="Freie Plätze (ohne Fahrer)" hidden>
<select id="guest-rideswith" title="Fährt am liebsten mit bei"></select>
<button type="submit">Hinzufügen</button>
</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.
Alle anderen werden bei der Berechnung automatisch verteilt.</p>
<form id="fixed-form" class="row wrap">
<select id="fixed-passenger" title="Wer"></select>
<span class="muted">fährt bei</span>
<select id="fixed-driver" title="Fahrer"></select>
<button type="submit">Festlegen</button>
</form>
<ul id="fixed-list" class="list"></ul>
</div>
<div class="card">
<h2 class="no-print">Einteilung</h2>
<div class="no-print">
<label class="inline">
<input type="checkbox" id="opt-min-cars" checked> Möglichst wenige Autos verwenden
</label>
<p class="muted">Wer „fährt immer“ hat und sein Auto dabei hat, fährt trotzdem selbst und bekommt zuerst Mitfahrer.</p>
<p class="muted">Treffpunkt und Zeiten kannst du danach bei jedem Auto einzeln ändern.
Fahrer mit eigenem Standard-Treffpunkt (unter Gruppe) starten automatisch dort.</p>
<div class="row">
<button id="plan-btn" class="primary">Fahrgemeinschaften berechnen</button>
</div>
</div>
<div id="result"></div>
</div>
</main>
<script type="module" src="/js/fahrt.js"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gruppe – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div class="card">
<h2 id="member-form-title">Mitglied hinzufügen</h2>
<form id="member-form">
<input type="hidden" id="member-id">
<label>Vorname
<input type="text" id="member-name" required autocomplete="off" maxlength="50">
</label>
<label class="inline">
<input type="checkbox" id="member-external"> Extern (gehört nicht zur Gruppe, fährt aber manchmal mit)
</label>
<label class="inline">
<input type="checkbox" id="member-hascar"> Hat Auto
</label>
<label>Fährt am liebsten mit bei
<select id="member-rideswith"></select>
</label>
<div id="member-seats-wrap" hidden>
<label>Freie Plätze (ohne Fahrer)
<input type="number" id="member-seats" min="1" max="8" value="4">
</label>
<label class="inline">
<input type="checkbox" id="member-always"> Kommt immer mit eigenem Auto
</label>
<p class="muted">Fährt dann bei jeder Fahrt selbst, auch wenn weniger Autos reichen würden.</p>
<label>Standard-Treffpunkt, wenn er/sie fährt
<select id="member-point"></select>
</label>
</div>
<div class="row">
<button type="submit" class="primary">Speichern</button>
<button type="button" id="member-cancel" hidden>Abbrechen</button>
</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">
<input type="hidden" id="point-id">
<label>Name
<input type="text" id="point-name" required autocomplete="off" maxlength="60" placeholder="z. B. Parkplatz Bahnhof">
</label>
<label>Adresse
<input type="text" id="point-address" required autocomplete="off" maxlength="200" placeholder="Straße Nr., PLZ Ort">
</label>
<div class="row">
<button type="submit" class="primary">Speichern</button>
<button type="button" id="point-cancel" hidden>Abbrechen</button>
</div>
</form>
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
<ul id="points-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>
<div class="row">
<button id="export-btn">Exportieren (JSON)</button>
<label class="button">Importieren
<input type="file" id="import-file" accept="application/json" hidden>
</label>
</div>
</div>
</main>
<script type="module" src="/js/gruppe.js"></script>
</body>
</html>
+519
View File
@@ -0,0 +1,519 @@
import {
$, api, esc, sortByName, seatsLabel, showError, renderHeader, formatDate,
} from './common.js';
renderHeader('fahrt');
let members = [];
let points = [];
const DEFAULT_DEPARTURE = '07:00';
const MEET_BEFORE_MIN = 15;
// Zustand der aktuellen Fahrt (nur im Browser, wird nicht gespeichert)
const trip = {
selected: new Set(), // IDs der mitfahrenden Mitglieder
bringsCar: new Map(), // ID -> bool, ob das Auto diesmal dabei ist
guests: [], // { id, name, hasCar, seats, guest: true }
fixed: new Map(), // Mitfahrer-ID -> Fahrer-ID, feste Zuteilung für diese Fahrt
};
let guestCounter = 0;
// ---------- Treffpunkt und Uhrzeiten ----------
function renderPoints() {
$('#trip-points-empty').hidden = points.length > 0;
$('#trip-point').innerHTML = points.length
? points.map((p) => `<option value="${p.id}">${esc(p.name)} – ${esc(p.address)}</option>`).join('')
+ '<option value="">(kein Treffpunkt)</option>'
: '<option value="">(noch keine Treffpunkte angelegt)</option>';
}
// "07:00" minus 15 Minuten → "06:45" (auch über Mitternacht)
function minusMinutes(time, min) {
const [h, m] = time.split(':').map(Number);
const total = (((h * 60 + m - min) % 1440) + 1440) % 1440;
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function setDeparture(time) {
$('#trip-departure').value = time;
$('#trip-meet').value = minusMinutes(time, MEET_BEFORE_MIN);
}
// Treffzeit folgt der Abfahrt, kann danach aber noch von Hand angepasst werden
$('#trip-departure').addEventListener('change', (e) => {
if (e.target.value) setDeparture(e.target.value);
});
// ---------- Mitglieder auswählen ----------
function renderTripMembers() {
$('#trip-members-empty').hidden = members.length > 0;
const nameOf = (id) => members.find((m) => m.id === id)?.name;
$('#trip-members').innerHTML = sortByName(members).map((m) => {
if (!trip.bringsCar.has(m.id)) trip.bringsCar.set(m.id, m.hasCar);
const checked = trip.selected.has(m.id);
return `
<li>
<label class="inline name">
<input type="checkbox" data-select="${m.id}" ${checked ? 'checked' : ''}> ${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}
${nameOf(m.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(m.ridesWith))})</span>` : ''}
</label>
<span class="actions">
${m.hasCar ? `
<label class="inline meta">
<input type="checkbox" data-car="${m.id}" ${trip.bringsCar.get(m.id) ? 'checked' : ''} ${checked ? '' : 'disabled'}>
Auto dabei (${seatsLabel(m.seats)})
</label>
${m.alwaysDrives ? '<span class="badge" title="Kommt immer mit eigenem Auto">fährt immer</span>' : ''}` : '<span class="meta">kein Auto</span>'}
</span>
</li>`;
}).join('');
renderFixed();
}
$('#trip-members').addEventListener('change', (e) => {
const { select, car } = e.target.dataset;
if (select) {
e.target.checked ? trip.selected.add(select) : trip.selected.delete(select);
renderTripMembers();
}
if (car) {
trip.bringsCar.set(car, e.target.checked);
renderFixed();
}
});
$('#select-all').addEventListener('click', () => {
members.forEach((m) => trip.selected.add(m.id));
renderTripMembers();
});
$('#select-none').addEventListener('click', () => {
trip.selected.clear();
renderTripMembers();
});
// ---------- Gäste ----------
$('#guest-hascar').addEventListener('change', (e) => {
$('#guest-seats').hidden = !e.target.checked;
});
$('#guest-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('#guest-name').value.trim();
if (!name) return;
const hasCar = $('#guest-hascar').checked;
trip.guests.push({
id: 'guest-' + ++guestCounter,
name,
hasCar,
seats: hasCar ? Math.max(1, parseInt($('#guest-seats').value, 10) || 1) : 0,
ridesWith: $('#guest-rideswith').value || null,
guest: true,
});
e.target.reset();
$('#guest-seats').hidden = true;
renderGuests();
$('#guest-name').focus();
});
function renderGuests() {
const people = [...sortByName(members), ...trip.guests];
const nameOf = (id) => people.find((p) => p.id === id)?.name;
$('#guest-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
+ people.map((p) => `<option value="${p.id}">gern bei ${esc(p.name)}</option>`).join('');
$('#guests-list').innerHTML = trip.guests.map((g) => `
<li>
<span class="name">${esc(g.name)} <span class="badge">Gast</span>
${nameOf(g.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(g.ridesWith))})</span>` : ''}</span>
<span class="meta">${g.hasCar ? `Auto · ${seatsLabel(g.seats)}` : 'kein Auto'}</span>
<button class="link danger" data-remove="${g.id}">Entfernen</button>
</li>`).join('');
renderFixed();
}
$('#guests-list').addEventListener('click', (e) => {
const id = e.target.dataset.remove;
if (!id) return;
trip.guests = trip.guests.filter((g) => g.id !== id);
for (const g of trip.guests) if (g.ridesWith === id) g.ridesWith = null;
renderGuests();
});
// ---------- Feste Zuteilung ----------
// Alle, die diesmal mitfahren (Mitglieder mit Auto-Häkchen der Fahrt, plus Gäste)
function currentParticipants() {
return [
...sortByName(members.filter((m) => trip.selected.has(m.id))).map((m) => ({
...m,
hasCar: m.hasCar && trip.bringsCar.get(m.id) !== false,
})),
...trip.guests,
];
}
function renderFixed() {
const people = currentParticipants();
const byId = new Map(people.map((p) => [p.id, p]));
const canDrive = (p) => p?.hasCar && p.seats > 0;
// Einträge entfernen, die nicht mehr passen (abgewählt, Auto nicht dabei, Auto voll)
const used = new Map();
for (const [passengerId, driverId] of trip.fixed) {
const n = (used.get(driverId) || 0) + 1;
if (!byId.has(passengerId) || !canDrive(byId.get(driverId)) || trip.fixed.has(driverId)
|| n > byId.get(driverId).seats) {
trip.fixed.delete(passengerId);
} else {
used.set(driverId, n);
}
}
const fixedDrivers = new Set(trip.fixed.values());
const passengers = people.filter((p) => !trip.fixed.has(p.id) && !fixedDrivers.has(p.id));
const drivers = people.filter((p) => canDrive(p) && !trip.fixed.has(p.id) && (used.get(p.id) || 0) < p.seats);
$('#fixed-passenger').innerHTML = passengers.length
? passengers.map((p) => `<option value="${p.id}">${esc(p.name)}</option>`).join('')
: '<option value="">(niemand mehr frei)</option>';
$('#fixed-driver').innerHTML = drivers.length
? drivers.map((d) => `<option value="${d.id}">${esc(d.name)} (${d.seats - (used.get(d.id) || 0)} frei)</option>`).join('')
: '<option value="">(kein Auto dabei)</option>';
$('#fixed-form button').disabled = !passengers.length || !drivers.length;
$('#fixed-list').innerHTML = [...trip.fixed].map(([passengerId, driverId]) => `
<li>
<span class="name">${esc(byId.get(passengerId).name)} <span class="muted">fährt bei</span> ${esc(byId.get(driverId).name)}</span>
<button class="link danger" data-unfix="${passengerId}">Entfernen</button>
</li>`).join('');
}
$('#fixed-form').addEventListener('submit', (e) => {
e.preventDefault();
const passengerId = $('#fixed-passenger').value;
const driverId = $('#fixed-driver').value;
if (!passengerId || !driverId || passengerId === driverId) return;
trip.fixed.set(passengerId, driverId);
renderFixed();
});
$('#fixed-list').addEventListener('click', (e) => {
const id = e.target.dataset.unfix;
if (!id) return;
trip.fixed.delete(id);
renderFixed();
});
// ---------- Zurücksetzen ----------
function resetTrip() {
trip.selected.clear();
trip.bringsCar.clear();
trip.guests = [];
trip.fixed.clear();
$('#trip-title').value = '';
$('#trip-date').value = '';
$('#trip-point').selectedIndex = 0;
setDeparture(DEFAULT_DEPARTURE);
$('#opt-min-cars').checked = true;
$('#guest-form').reset();
$('#guest-seats').hidden = true;
$('#result').innerHTML = '';
$('#from-event').hidden = true;
history.replaceState(null, '', location.pathname);
renderTripMembers();
renderGuests();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('#reset-btn').addEventListener('click', resetTrip);
// ---------- Einteilung berechnen ----------
/**
* Verteilt die Teilnehmer auf Autos.
* - Feste Zuteilung (fixed: Mitfahrer-ID -> Fahrer-ID): der Fahrer fährt sicher, der Mitfahrer sitzt sicher bei ihm.
* - Wer immer mit eigenem Auto kommt (alwaysDrives) und es dabeihat, fährt auf jeden Fall.
* - minCars: weitere Autos nur so viele wie nötig (größte zuerst), sonst alle Autos.
* Wer lieber bei einem anderen Autobesitzer mitfährt (ridesWith), wird erst zuletzt Fahrer.
* - Wunsch-Mitfahrer: wer bei einem Fahrer mitfahren möchte, kommt zuerst in dessen Auto;
* Mitfahrer mit Vorliebe füreinander kommen nach Möglichkeit ins selbe Auto.
* - Übrige Mitfahrer: zuerst bekommt jedes Auto jemanden (Immer-Fahrer zuerst), danach gleichmäßig auffüllen.
*/
function planTrip(participants, minCars, fixed = new Map()) {
const byId = new Map(participants.map((p) => [p.id, p]));
const hasUsableCar = (p) => !!(p?.hasCar && p.seats > 0);
const prefersOtherCar = (p) => hasUsableCar(byId.get(p.ridesWith));
const fixedDrivers = new Set(fixed.values());
const mustDrive = (p) => p.alwaysDrives || fixedDrivers.has(p.id);
// Fest zugeteilte Mitfahrer fahren nie selbst
const carOwners = participants.filter((p) => hasUsableCar(p) && !fixed.has(p.id));
const forced = carOwners.filter(mustDrive).sort((a, b) => b.seats - a.seats);
const optional = carOwners.filter((p) => !mustDrive(p))
.sort((a, b) => prefersOtherCar(a) - prefersOtherCar(b) || b.seats - a.seats);
let drivers = [...forced, ...optional];
if (minCars) {
drivers = [...forced];
let capacity = forced.reduce((sum, d) => sum + 1 + d.seats, 0);
for (const owner of optional) {
if (capacity >= participants.length) break;
drivers.push(owner);
capacity += 1 + owner.seats;
}
}
const driverIds = new Set(drivers.map((d) => d.id));
const cars = drivers.map((d) => ({ driver: d, passengers: [] }));
const carOf = new Map(cars.map((c) => [c.driver.id, c]));
const unassigned = [];
const free = (car) => car.driver.seats - car.passengers.length;
const place = (p, car) => { car.passengers.push(p); carOf.set(p.id, car); };
// Auto mit den wenigsten Mitfahrern, bei Gleichstand Immer-Fahrer, dann die meisten freien Plätze
function pickCar(size) {
let best = null;
for (const car of cars) {
if (free(car) < size) continue;
if (!best
|| car.passengers.length < best.passengers.length
|| (car.passengers.length === best.passengers.length
&& (car.driver.alwaysDrives && !best.driver.alwaysDrives
|| (!!car.driver.alwaysDrives === !!best.driver.alwaysDrives && free(car) > free(best))))) {
best = car;
}
}
return best;
}
// 0. Feste Zuteilung für diese Fahrt
for (const p of participants) {
const car = carOf.get(fixed.get(p.id));
if (car && car.driver.id === fixed.get(p.id) && free(car) > 0) place(p, car);
}
// 1. Wer bei einem Fahrer mitfahren möchte, kommt in dessen Auto
let rest = [];
for (const p of participants) {
if (carOf.has(p.id)) continue;
const car = carOf.get(p.ridesWith);
if (car && car.driver.id === p.ridesWith && free(car) > 0) place(p, car);
else rest.push(p);
}
// 2. Übrige Mitfahrer in Gruppen zusammenfassen, die über Vorlieben verbunden sind
const groupOf = new Map(rest.map((p) => [p.id, [p]]));
for (const p of rest) {
const a = groupOf.get(p.id);
const b = groupOf.get(p.ridesWith);
if (!b || a === b) continue;
a.push(...b);
for (const q of b) groupOf.set(q.id, a);
}
const groups = [...new Set(groupOf.values())].sort((a, b) => b.length - a.length);
// 3. Gruppen verteilen – bevorzugt ins Auto, in dem die Wunschperson schon sitzt
for (const group of groups) {
const wished = group.map((p) => carOf.get(p.ridesWith)).find((c) => c && free(c) >= group.length);
const car = wished || pickCar(group.length);
if (car) {
group.forEach((p) => place(p, car));
continue;
}
for (const p of group) {
const own = carOf.get(p.ridesWith);
const c = own && free(own) > 0 ? own : pickCar(1);
c ? place(p, c) : unassigned.push(p);
}
}
return { cars, unassigned };
}
// ---------- Ergebnis ----------
const badges = (p) => (p.guest ? ' <span class="badge">Gast</span>' : '')
+ (p.external ? ' <span class="badge">Extern</span>' : '');
const sameMeeting = (a, b) => a.pointId === b.pointId && a.meet === b.meet && a.departure === b.departure;
function meetingLines({ pointId, meet, departure }) {
const point = points.find((p) => p.id === pointId);
return [
point && `Treffpunkt: ${point.name}, ${point.address}`,
meet && `Treffzeit: ${meet} Uhr`,
departure && `Abfahrt: ${departure} Uhr`,
].filter(Boolean);
}
function meetingHtml({ pointId, meet, departure }) {
const point = points.find((p) => p.id === pointId);
let html = '<dl class="meeting">';
if (point) {
const maps = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(point.address)}`;
html += `<dt>Treffpunkt</dt><dd>${esc(point.name)}
<small><a href="${maps}" target="_blank" rel="noopener">${esc(point.address)}</a></small></dd>`;
}
if (meet) html += `<dt>Treffzeit</dt><dd>${esc(meet)} Uhr</dd>`;
if (departure) html += `<dt>Abfahrt</dt><dd>${esc(departure)} Uhr</dd>`;
return html + '</dl>';
}
// Haben alle Autos denselben Treffpunkt und dieselben Zeiten, steht das einmal oben, sonst bei jedem Auto
function commonMeeting(carMeet, defaults) {
if (!carMeet.length) return defaults;
return carMeet.every((m) => sameMeeting(m, carMeet[0])) ? carMeet[0] : null;
}
function buildText(heading, { cars, unassigned }, carMeet, defaults) {
const lines = [`Fahrgemeinschaften${heading ? ': ' + heading : ''}`, ''];
const common = commonMeeting(carMeet, defaults);
if (common) {
const ml = meetingLines(common);
if (ml.length) lines.push(...ml, '');
}
cars.forEach((car, i) => {
if (i > 0 && !common) lines.push('');
lines.push(`Fahrzeug ${i + 1} – Fahrer: ${car.driver.name} (${car.passengers.length}/${car.driver.seats} belegt)`);
if (!common) meetingLines(carMeet[i]).forEach((l) => lines.push(` ${l}`));
car.passengers.forEach((p) => lines.push(` • ${p.name}`));
if (!car.passengers.length) lines.push(' • (keine Mitfahrer)');
});
if (unassigned.length) {
lines.push('', `OHNE PLATZ: ${unassigned.map((p) => p.name).join(', ')}`);
}
return lines.join('\n');
}
$('#plan-btn').addEventListener('click', () => {
const participants = currentParticipants();
const result = $('#result');
if (!participants.length) {
result.innerHTML = '<p class="warn">Bitte wähle mindestens eine Person aus.</p>';
return;
}
const plan = planTrip(participants, $('#opt-min-cars').checked, trip.fixed);
const heading = [$('#trip-title').value.trim(), formatDate($('#trip-date').value)].filter(Boolean).join(' – ');
const totalSeats = plan.cars.reduce((s, c) => s + 1 + c.driver.seats, 0);
// Treffpunkt und Zeiten pro Auto: Standard-Treffpunkt des Fahrers, sonst der Treffpunkt der Fahrt
const defaults = {
pointId: $('#trip-point').value,
meet: $('#trip-meet').value,
departure: $('#trip-departure').value,
};
const carMeet = plan.cars.map((car) => ({
...defaults,
pointId: points.some((p) => p.id === car.driver.meetingPoint) ? car.driver.meetingPoint : defaults.pointId,
}));
const pointOptions = (selected) => points
.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('')
+ `<option value="" ${selected ? '' : 'selected'}>(kein Treffpunkt)</option>`;
let html = heading ? `<h3>${esc(heading)}</h3>` : '';
html += '<div id="meeting-common"></div>';
html += `<p class="muted">${participants.length} Personen · ${plan.cars.length} ${plan.cars.length === 1 ? 'Auto' : 'Autos'} · ${totalSeats} Sitzplätze</p>`;
html += '<div class="cars">' + plan.cars.map((car, i) => `
<div class="car">
<h3>Auto ${i + 1}: ${esc(car.driver.name)}${badges(car.driver)}</h3>
<div class="muted">${car.passengers.length} / ${car.driver.seats} Plätze belegt</div>
<div class="car-meet no-print">
<label>Treffpunkt <select data-car-point="${i}">${pointOptions(carMeet[i].pointId)}</select></label>
<div class="grid2">
<label>Treffzeit <input type="time" data-car-meet="${i}" value="${esc(carMeet[i].meet)}"></label>
<label>Abfahrt <input type="time" data-car-dep="${i}" value="${esc(carMeet[i].departure)}"></label>
</div>
</div>
<div data-car-info="${i}"></div>
${car.passengers.length
? `<ol>${car.passengers.map((p) => `<li>${esc(p.name)}${badges(p)}${trip.fixed.get(p.id) === car.driver.id ? ' <span class="badge">fest</span>' : ''}</li>`).join('')}</ol>`
: '<p class="muted">keine Mitfahrer</p>'}
</div>`).join('') + '</div>';
if (plan.unassigned.length) {
const missing = plan.unassigned.length;
html += `<div class="warn"><strong>Nicht genug Plätze!</strong> Es fehlen ${seatsLabel(missing)}.<br>
Ohne Platz: ${plan.unassigned.map((p) => esc(p.name)).join(', ')}</div>`;
}
html += `
<div class="row wrap no-print">
<button id="copy-btn">Als Text kopieren</button>
<button id="print-btn">Drucken</button>
<button id="reset-after-btn">Neue Fahrt</button>
</div>`;
result.innerHTML = html;
function renderMeetings() {
const common = commonMeeting(carMeet, defaults);
$('#meeting-common').innerHTML = common ? meetingHtml(common) : '';
carMeet.forEach((m, i) => {
$(`[data-car-info="${i}"]`).innerHTML = common ? '' : meetingHtml(m);
});
}
renderMeetings();
result.querySelector('.cars').addEventListener('change', (e) => {
const { carPoint, carMeet: meetIdx, carDep } = e.target.dataset;
if (carPoint !== undefined) carMeet[carPoint].pointId = e.target.value;
if (meetIdx !== undefined) carMeet[meetIdx].meet = e.target.value;
if (carDep !== undefined) {
// Treffzeit folgt der Abfahrt wie oben bei der Fahrt
const m = carMeet[carDep];
m.departure = e.target.value;
if (m.departure) {
m.meet = minusMinutes(m.departure, MEET_BEFORE_MIN);
$(`[data-car-meet="${carDep}"]`).value = m.meet;
}
}
$('#copy-btn').textContent = 'Als Text kopieren';
renderMeetings();
});
$('#copy-btn').addEventListener('click', async (e) => {
const text = buildText(heading, plan, carMeet, defaults);
try {
await navigator.clipboard.writeText(text);
e.target.textContent = 'Kopiert';
} catch {
prompt('Text zum Kopieren:', text);
}
});
$('#print-btn').addEventListener('click', () => window.print());
$('#reset-after-btn').addEventListener('click', resetTrip);
});
// ---------- Übernahme aus Kalender-Termin ----------
async function applyEventFromUrl() {
const eventId = new URLSearchParams(location.search).get('termin');
if (!eventId) return;
let ev;
try {
ev = await api('GET', `/events/${encodeURIComponent(eventId)}`);
} catch {
showError(new Error('Der Termin wurde nicht gefunden.'));
return;
}
const known = ev.attendees.filter((id) => members.some((m) => m.id === id));
known.forEach((id) => trip.selected.add(id));
$('#trip-title').value = ev.title;
$('#trip-date').value = ev.date;
const notice = $('#from-event');
notice.innerHTML = `Übernommen aus dem Kalender: <strong>${esc(ev.title)}</strong> am ${formatDate(ev.date, true)} –
${known.length} ${known.length === 1 ? 'Zusage ist' : 'Zusagen sind'} schon als „kommt mit“ ausgewählt.`;
notice.hidden = false;
}
// ---------- Start ----------
async function init() {
[members, points] = await Promise.all([api('GET', '/members'), api('GET', '/meeting-points')]);
renderPoints();
await applyEventFromUrl();
renderTripMembers();
renderGuests();
}
init().catch(showError);
+216
View File
@@ -0,0 +1,216 @@
import { $, api, esc, sortByName, seatsLabel, showError, renderHeader } from './common.js';
renderHeader('gruppe');
let members = [];
let points = [];
const form = $('#member-form');
$('#member-hascar').addEventListener('change', (e) => {
$('#member-seats-wrap').hidden = !e.target.checked;
});
// Auswahl "fährt am liebsten mit bei" – alle anderen Mitglieder
function fillRidesWith(selfId, selected) {
$('#member-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
+ sortByName(members.filter((m) => m.id !== selfId))
.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
}
// Auswahl Standard-Treffpunkt
function fillMemberPoint(selected) {
$('#member-point').innerHTML = '<option value="">(Treffpunkt der Fahrt)</option>'
+ points.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
}
function resetForm() {
form.reset();
fillRidesWith();
fillMemberPoint();
$('#member-id').value = '';
$('#member-seats-wrap').hidden = true;
$('#member-cancel').hidden = true;
$('#member-form-title').textContent = 'Mitglied hinzufügen';
}
$('#member-cancel').addEventListener('click', resetForm);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
name: $('#member-name').value.trim(),
external: $('#member-external').checked,
hasCar: $('#member-hascar').checked,
seats: $('#member-seats').value,
alwaysDrives: $('#member-always').checked,
ridesWith: $('#member-rideswith').value || null,
meetingPoint: $('#member-point').value || null,
};
const id = $('#member-id').value;
try {
if (id) await api('PUT', `/members/${id}`, body);
else await api('POST', '/members', body);
resetForm();
await load();
} catch (err) {
showError(err);
}
});
function edit(id) {
const m = members.find((x) => x.id === id);
$('#member-id').value = m.id;
$('#member-name').value = m.name;
$('#member-external').checked = !!m.external;
fillMemberPoint(m.meetingPoint);
$('#member-hascar').checked = m.hasCar;
$('#member-seats').value = m.seats || 4;
$('#member-always').checked = !!m.alwaysDrives;
fillRidesWith(m.id, m.ridesWith);
$('#member-seats-wrap').hidden = !m.hasCar;
$('#member-cancel').hidden = false;
$('#member-form-title').textContent = 'Mitglied bearbeiten';
$('#member-name').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function remove(id) {
const m = members.find((x) => x.id === id);
if (!confirm(`${m.name} wirklich aus der Gruppe löschen?`)) return;
try {
await api('DELETE', `/members/${id}`);
await load();
} catch (err) {
showError(err);
}
}
function render() {
if (!$('#member-id').value) fillRidesWith();
const nameOf = (id) => members.find((m) => m.id === id)?.name;
const pointOf = (id) => points.find((p) => p.id === id)?.name;
$('#members-empty').hidden = members.length > 0;
$('#members-list').innerHTML = sortByName(members).map((m) => `
<li>
<span class="name">${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}</span>
${m.hasCar && pointOf(m.meetingPoint) ? `<span class="meta">ab ${esc(pointOf(m.meetingPoint))}</span>` : ''}
${nameOf(m.ridesWith) ? `<span class="meta">fährt gern bei ${esc(nameOf(m.ridesWith))} mit</span>` : ''}
<span class="badge">${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'}</span>
<span class="actions">
<button class="link" data-edit="${m.id}">Bearbeiten</button>
<button class="link danger" data-del="${m.id}">Löschen</button>
</span>
</li>`).join('');
}
$('#members-list').addEventListener('click', (e) => {
if (e.target.dataset.edit) edit(e.target.dataset.edit);
if (e.target.dataset.del) remove(e.target.dataset.del);
});
$('#export-btn').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(members, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'hcrb-gruppe.json';
a.click();
URL.revokeObjectURL(a.href);
});
$('#import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
let data;
try {
data = JSON.parse(await file.text());
if (!Array.isArray(data)) throw new Error();
} catch {
alert('Die Datei konnte nicht gelesen werden.');
return;
}
if (!confirm(`${data.length} Mitglieder importieren? Die aktuelle Gruppe wird ersetzt.`)) return;
try {
await api('POST', '/members/import', data);
await load();
} catch (err) {
showError(err);
}
});
// ---------- Treffpunkte ----------
const pointForm = $('#point-form');
function resetPointForm() {
pointForm.reset();
$('#point-id').value = '';
$('#point-cancel').hidden = true;
$('#point-form-title').textContent = 'Treffpunkt hinzufügen';
}
$('#point-cancel').addEventListener('click', resetPointForm);
pointForm.addEventListener('submit', async (e) => {
e.preventDefault();
const body = { name: $('#point-name').value.trim(), address: $('#point-address').value.trim() };
const id = $('#point-id').value;
try {
if (id) await api('PUT', `/meeting-points/${id}`, body);
else await api('POST', '/meeting-points', body);
resetPointForm();
await loadPoints();
} catch (err) {
showError(err);
}
});
function renderPoints() {
$('#points-empty').hidden = points.length > 0;
$('#points-list').innerHTML = points.map((p) => `
<li>
<span class="name">${esc(p.name)}</span>
<span class="meta">${esc(p.address)}</span>
<span class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-del="${p.id}">Löschen</button>
</span>
</li>`).join('');
}
$('#points-list').addEventListener('click', async (e) => {
const { edit: editId, del } = e.target.dataset;
if (editId) {
const p = points.find((x) => x.id === editId);
$('#point-id').value = p.id;
$('#point-name').value = p.name;
$('#point-address').value = p.address;
$('#point-cancel').hidden = false;
$('#point-form-title').textContent = 'Treffpunkt bearbeiten';
$('#point-name').focus();
}
if (del) {
const p = points.find((x) => x.id === del);
if (!confirm(`Treffpunkt „${p.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/meeting-points/${del}`);
await loadPoints();
} catch (err) {
showError(err);
}
}
});
async function loadPoints() {
points = await api('GET', '/meeting-points');
renderPoints();
const editing = members.find((m) => m.id === $('#member-id').value);
fillMemberPoint(editing ? $('#member-point').value : undefined);
render();
}
async function load() {
members = await api('GET', '/members');
render();
}
load().then(loadPoints).catch(showError);
+182
View File
@@ -0,0 +1,182 @@
import {
$, api, esc, sortByName, showError, renderHeader,
toIso, fromIso, formatDate, getMe, setMe,
} from './common.js';
renderHeader('kalender');
let members = [];
let events = [];
let me = getMe();
const todayIso = toIso(new Date());
let selectedDay = todayIso;
let viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
const memberName = (id) => members.find((m) => m.id === id)?.name ?? '(gelöscht)';
// ---------- "Ich bin" ----------
function renderMe() {
if (me && !members.some((m) => m.id === me)) me = null;
$('#me-select').innerHTML = '<option value="">– bitte auswählen –</option>' +
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;
}
$('#me-select').addEventListener('change', (e) => {
me = e.target.value || null;
setMe(me);
renderAll();
});
// ---------- Monatsansicht ----------
function renderCalendar() {
const title = viewMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
$('#month-title').textContent = title;
// Raster beginnt am Montag vor (oder am) Monatsersten
const start = new Date(viewMonth);
start.setDate(1 - ((start.getDay() + 6) % 7));
const byDate = new Map();
for (const ev of events) {
if (!byDate.has(ev.date)) byDate.set(ev.date, []);
byDate.get(ev.date).push(ev);
}
let html = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((d) => `<div class="dow">${d}</div>`).join('');
const day = new Date(start);
for (let i = 0; i < 42; i++) {
const iso = toIso(day);
const classes = ['cal-day'];
if (day.getMonth() !== viewMonth.getMonth()) classes.push('other');
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('')}
</button>`;
day.setDate(day.getDate() + 1);
}
$('#calendar').innerHTML = html;
}
$('#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' });
});
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();
});
// ---------- Termine ----------
function eventHtml(ev) {
const going = ev.attendees.includes(me);
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
return `
<div class="event">
<h3>${esc(ev.title)}</h3>
<div class="when">${formatDate(ev.date, true)}${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">
${attendees.length
? attendees.map((a) => `<span class="chip ${a.id === me ? 'me' : ''}">${esc(a.name)}</span>`).join('')
: '<span class="muted">noch niemand</span>'}
</div>
<div class="row wrap">
${me ? (going
? `<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>
<button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button>
</div>
</div>`;
}
function renderDay() {
$('#day-title').textContent = fromIso(selectedDay).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
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>';
}
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>';
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
try {
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;
await api('DELETE', `/events/${id}`);
}
await load();
} catch (err) {
showError(err);
}
});
$('#event-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', '/events', {
date: selectedDay,
title: $('#event-title').value,
note: $('#event-note').value,
createdBy: me,
});
e.target.reset();
await load();
} catch (err) {
showError(err);
}
});
// ---------- Laden ----------
function renderAll() {
renderMe();
renderCalendar();
renderDay();
renderUpcoming();
}
async function load() {
[members, events] = await Promise.all([api('GET', '/members'), api('GET', '/events')]);
renderAll();
}
load().catch(showError);
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kalender – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</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>
</div>
<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>
</div>
<div class="row end"><button id="today-btn" class="small">Heute</button></div>
<div id="calendar" class="cal-grid"></div>
</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>
<label>Titel / Spielfeld
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Waldfeld">
</label>
<label>Notiz (optional)
<textarea id="event-note" maxlength="500" placeholder="Uhrzeit, Link zum Event, Treffpunkt …"></textarea>
</label>
<button type="submit" class="primary">Eintragen</button>
</form>
</div>
<div class="card">
<h2>Kommende Spieltage</h2>
<div id="upcoming"></div>
</div>
</main>
<script type="module" src="/js/kalender.js"></script>
</body>
</html>
+440
View File
@@ -0,0 +1,440 @@
/* Farben aus der Gruppenflagge: Marineblau #354173, Gold #e4b84a, Stahlgrau #cacaca */
:root {
color-scheme: dark;
--flag: #354173;
--bg: #1b2140;
--bg-deep: #141932;
--card: #232a4d;
--card-2: #2b335a;
--text: #e9eaf0;
--muted: #a1a7c2;
--border: #3d4775;
--accent: #e4b84a;
--accent-text: #141932;
--accent-soft: rgba(228, 184, 74, .14);
--steel: #cacaca;
--warn: #ec7f6a;
--chip: #333c66;
--radius: 2px;
--font-head: "Bahnschrift", "DIN Alternate", "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
/* Fußzeile bleibt auch auf kurzen Seiten unten */
min-height: 100vh;
display: flex;
flex-direction: column;
}
a { color: var(--accent); }
h1, h2, h3, .brand, .site-header nav a, button, .button, .badge, .cal-grid .dow {
font-family: var(--font-head);
}
h1, h2, h3 { text-transform: uppercase; letter-spacing: .08em; }
/* ---------- Kopfzeile ---------- */
.site-header {
background: var(--bg-deep);
border-bottom: 2px solid var(--accent);
padding: 10px 16px;
position: sticky;
top: 0;
z-index: 10;
}
.site-header .inner {
max-width: 960px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.brand {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 1.3rem;
letter-spacing: .18em;
color: var(--accent);
text-decoration: none;
}
.brand img { width: 40px; height: 40px; margin-right: 10px; flex: none; }
.brand small {
font-size: .72rem;
font-weight: 400;
color: var(--steel);
letter-spacing: .14em;
text-transform: uppercase;
margin-left: 10px;
}
.site-header nav { display: flex; gap: 2px; flex-wrap: wrap; margin-left: auto; }
.site-header nav a, .site-header nav button {
text-decoration: none;
color: var(--steel);
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: .1em;
font-size: .85rem;
border-bottom: 2px solid transparent;
}
.site-header nav button { background: none; border-width: 0 0 2px; border-radius: 0; }
.site-header nav a:hover, .site-header nav button:hover { color: var(--text); }
.site-header nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
main { max-width: 960px; width: 100%; margin: 0 auto; padding: 16px; flex: 1 0 auto; }
main.narrow { max-width: 760px; }
main.login { max-width: 420px; padding-top: 48px; }
/* ---------- Bausteine ---------- */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.card h2 {
font-size: .95rem;
margin: 0 0 14px;
padding-left: 10px;
border-left: 3px solid var(--accent);
}
.card h3 { font-size: .9rem; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.card-head h2 { margin: 0; }
.card-head select { width: auto; margin-top: 0; }
label { display: block; margin-bottom: 10px; font-size: .92rem; color: var(--muted); }
label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; }
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea {
display: block;
width: 100%;
margin-top: 4px;
padding: 8px 10px;
font: inherit;
color: var(--text);
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: var(--radius);
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
textarea { resize: vertical; min-height: 60px; }
.row input[type=text], .row input[type=number], .row select { width: auto; flex: 1 1 120px; margin-top: 0; }
.row input[type=number] { flex: 0 0 80px; }
button, .button {
font-size: .85rem;
text-transform: uppercase;
letter-spacing: .08em;
padding: 8px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--card-2);
color: var(--text);
cursor: pointer;
display: inline-block;
margin: 0;
text-decoration: none;
}
button:hover, .button:hover { border-color: var(--steel); }
button:disabled { opacity: .45; cursor: default; }
button.primary, .button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 700; }
button.primary:hover { filter: brightness(1.08); }
button.danger { color: var(--warn); }
button.link { background: none; border: none; padding: 4px 6px; color: var(--muted); }
button.link:hover { color: var(--text); }
button.small, .button.small { padding: 4px 10px; font-size: .78rem; }
.row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
.row.wrap { flex-wrap: wrap; }
.row.end { justify-content: flex-end; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 560px) { .grid2 { grid-template-columns: 1fr; } }
.muted { color: var(--muted); font-size: .9rem; }
.list { list-style: none; padding: 0; margin: 0; }
.list li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.list li:last-child { border-bottom: none; }
.list .name { flex: 1; }
/* Aktionen rücken nach rechts und brechen bei Platzmangel gemeinsam in die nächste Zeile um */
.list .actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.list li > .link:last-child { margin-left: auto; }
.list .meta { color: var(--muted); font-size: .85rem; }
.list label { margin: 0; }
.badge {
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .08em;
padding: 2px 8px;
border-radius: var(--radius);
border: 1px solid var(--border);
color: var(--steel);
white-space: nowrap;
}
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: .85rem; padding: 3px 10px; border-radius: var(--radius); background: var(--chip); }
.chip.me { background: var(--accent); color: var(--accent-text); }
.notice {
border: 1px solid var(--accent);
border-left-width: 4px;
background: var(--accent-soft);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 16px;
}
.warn {
color: var(--warn);
border: 1px solid var(--warn);
border-left-width: 4px;
border-radius: var(--radius);
padding: 10px 12px;
margin-top: 12px;
}
/* ---------- Startseite ---------- */
.hero {
position: relative;
height: max(380px, 72vh);
background:
linear-gradient(to bottom, transparent 55%, var(--bg) 100%),
var(--flag) url("/img/flagge.jpg") center 35% / contain no-repeat;
display: flex;
align-items: flex-end;
justify-content: center;
text-align: center;
padding: 0 16px 24px;
}
.hero h1 {
font-size: clamp(2.4rem, 7vw, 4rem);
margin: 0;
letter-spacing: .3em;
margin-right: -.3em; /* gleicht letter-spacing am Ende aus */
color: var(--accent);
text-shadow: 0 2px 12px rgba(0, 0, 0, .5);
}
.hero .subtitle {
font-family: var(--font-head);
text-transform: uppercase;
letter-spacing: .35em;
color: var(--steel);
font-size: clamp(.8rem, 2.4vw, 1.05rem);
border-top: 1px solid var(--accent);
padding-top: 8px;
margin-top: 4px;
}
@media (max-width: 560px) {
/* Hochformat: Flagge füllt die Fläche, damit das Zahnrad groß bleibt */
.hero { height: min(78vh, 480px); background-size: auto, auto 70%; background-position: center, center 20%; }
}
.intro { max-width: 680px; margin: 8px auto 0; text-align: center; color: var(--muted); }
.notice.wip { max-width: 680px; margin: 16px auto 0; text-align: center; }
.social { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.social .button { display: inline-flex; align-items: center; gap: 8px; }
.social svg { width: 18px; height: 18px; flex: none; }
.footer-links { margin-top: 8px; display: flex; gap: 16px; justify-content: center; }
.footer-links a { color: var(--muted); }
.footer-links a:hover { color: var(--accent); }
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
@media (max-width: 560px) { .tiles { grid-template-columns: 1fr; } }
.tile {
display: flex;
gap: 16px;
align-items: flex-start;
text-decoration: none;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: var(--radius);
padding: 20px;
transition: border-color .15s, background .15s;
}
.tile:hover { background: var(--card-2); border-color: var(--accent); }
.tile svg { flex: 0 0 36px; width: 36px; height: 36px; color: var(--accent); }
.tile h2 { margin: 0 0 4px; font-size: 1.1rem; }
.tile p { margin: 0; color: var(--muted); }
/* ---------- Kalender ---------- */
.cal-nav { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.cal-nav h2 { margin: 0; font-size: 1rem; text-align: center; flex: 1; border: none; padding: 0; }
.cal-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 3px;
}
.cal-grid .dow {
text-align: center;
font-size: .75rem;
color: var(--muted);
padding: 4px 0;
text-transform: uppercase;
letter-spacing: .1em;
}
.cal-day {
min-height: 72px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
background: var(--bg-deep);
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
font-family: inherit;
font-size: .85rem;
text-transform: none;
letter-spacing: 0;
overflow: hidden;
}
.cal-day.other { opacity: .35; }
.cal-day.today .num { background: var(--steel); color: var(--bg-deep); padding: 0 6px; }
.cal-day.selected { outline: 2px solid var(--accent); outline-offset: -1px; }
.cal-day .num { align-self: flex-start; font-weight: 600; }
.cal-day .ev {
font-size: .72rem;
background: var(--accent-soft);
border-left: 3px solid var(--accent);
padding: 1px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-day .ev.mine { background: var(--accent); color: var(--accent-text); }
@media (max-width: 560px) {
.cal-day { min-height: 52px; padding: 2px; }
.cal-day .ev { font-size: 0; padding: 0; height: 6px; }
}
.event {
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 10px;
background: var(--bg-deep);
}
.event h3 { margin: 0; font-size: .95rem; }
.event .when { color: var(--muted); font-size: .85rem; margin-bottom: 6px; }
.event .note { margin: 6px 0; white-space: pre-wrap; }
/* ---------- Statistik ---------- */
.stats .rank { color: var(--muted); min-width: 3ch; font-family: var(--font-head); }
.stat-total {
min-width: 3ch;
text-align: right;
font-family: var(--font-head);
font-size: 1.2rem;
font-weight: 700;
color: var(--accent);
}
/* ---------- Fahrtplaner ---------- */
.cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 12px; }
.car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); }
.car-meet label { font-size: .8rem; margin-bottom: 6px; }
.car-meet .grid2 { gap: 8px; grid-template-columns: 1fr 1fr; }
.car {
border: 1px solid var(--border);
border-top: 3px solid var(--steel);
border-radius: var(--radius);
padding: 12px;
background: var(--bg-deep);
}
.car h3 { margin: 0 0 4px; font-size: .9rem; }
.car ol { margin: 6px 0 0; padding-left: 20px; }
.meeting { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 8px 0 4px; }
.meeting dt { color: var(--muted); }
.meeting dd { margin: 0; font-weight: 600; }
.meeting dd small { display: block; font-weight: 400; color: var(--muted); }
footer {
text-align: center;
color: var(--muted);
font-family: var(--font-head);
text-transform: uppercase;
letter-spacing: .2em;
font-size: .75rem;
padding: 24px 16px;
border-top: 1px solid var(--border);
}
/* ---------- Responsiv ---------- */
.nav-toggle { display: none; }
@media (max-width: 900px) {
.site-header { padding: 8px 16px; }
.site-header .inner { gap: 8px; }
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 44px;
height: 44px;
padding: 0;
background: none;
color: var(--steel);
}
.site-header.open .nav-toggle { color: var(--accent); border-color: var(--accent); }
.site-header nav { display: none; width: 100%; flex-direction: column; margin: 4px 0 0; gap: 0; }
.site-header.open nav { display: flex; }
.site-header nav a, .site-header nav button {
padding: 12px 4px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.site-header nav a:last-child, .site-header nav button:last-child { border-bottom: none; }
.site-header nav a.active { border-bottom-color: var(--border); border-left: 3px solid var(--accent); padding-left: 10px; }
}
@media (max-width: 380px) {
.brand small { display: none; }
}
@media (max-width: 760px) {
main { padding: 12px; }
.card { padding: 14px; }
/* iOS zoomt bei Schrift unter 16px ins Feld */
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea { font-size: 16px; }
.stats .meta { flex-basis: 100%; order: 10; padding-left: calc(3ch + 10px); margin-top: -6px; }
}
/* Touch-Geräte: ausreichend große Tippflächen */
@media (pointer: coarse) {
button.small, .button.small { min-height: 36px; min-width: 36px; }
button.link { padding: 8px 8px; }
input[type=checkbox] { width: 20px; height: 20px; }
}
@media print {
.site-header, footer, .no-print { display: none !important; }
:root { --text: #000; --muted: #444; --border: #999; --bg-deep: #fff; --card: #fff; }
body { background: #fff; color: #000; }
.card { border: none; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HCRB – Highcap Rasselbande</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<section class="hero">
<div>
<h1>HCRB</h1>
<div class="subtitle">Highcap Rasselbande</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>
<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">
<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>
</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>
<div class="card">
<div class="card-head">
<h2>Nächste Spieltage</h2>
<a href="/kalender" class="button small">Zum Kalender</a>
</div>
<div id="upcoming"><p class="muted">Lädt …</p></div>
</div>
<div class="card">
<div class="card-head">
<h2>Pile of Shame</h2>
<a href="/statistik" class="button small">Zur Statistik</a>
</div>
<div id="shame"><p class="muted">Lädt …</p></div>
</div>
<div class="card">
<h2>Über uns</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.
</p>
<p>
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
gibt es auf YouTube und Instagram:
</p>
<div class="social">
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/></svg>YouTube
</a>
<a class="button" href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.6" cy="6.4" r="1" fill="currentColor" stroke="none"/></svg>Instagram
</a>
</div>
</div>
</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>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
// Gemeinsame Helfer für alle Seiten
export const $ = (sel, root = document) => root.querySelector(sel);
export function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
export function sortByName(list) {
return [...list].sort((a, b) => a.name.localeCompare(b.name, 'de'));
}
export function seatsLabel(n) {
return `${n} ${n === 1 ? 'Platz' : 'Plätze'}`;
}
// ---------- API ----------
export async function api(method, path, body) {
const res = await fetch('/api' + path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && path !== '/login') {
// Sitzung abgelaufen → zur Anmeldung und danach hierher zurück
location.href = `/login?next=${encodeURIComponent(location.pathname + location.search)}`;
return new Promise(() => {});
}
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`);
return data;
}
export function showError(err) {
alert(err.message || 'Es ist ein Fehler aufgetreten.');
}
// ---------- Datum ----------
// Datumswerte werden als "YYYY-MM-DD" gespeichert (ohne Zeitzone)
export function toIso(d) {
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function fromIso(iso) {
const [y, m, d] = iso.split('-').map(Number);
return new Date(y, m - 1, d);
}
export function formatDate(iso, withWeekday = false) {
if (!iso) return '';
return fromIso(iso).toLocaleDateString('de-DE', {
weekday: withWeekday ? 'short' : undefined,
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
// ---------- "Ich bin ..." (pro Browser gemerkt) ----------
const ME_KEY = 'hcrb.me';
export function getMe() {
try { return localStorage.getItem(ME_KEY); } catch { return null; }
}
export function setMe(id) {
try {
id ? localStorage.setItem(ME_KEY, id) : localStorage.removeItem(ME_KEY);
} catch { /* ignorieren */ }
}
// ---------- Kopfzeile ----------
const NAV = [
{ href: '/', key: 'home', label: 'Start' },
{ href: '/kalender', key: 'kalender', label: 'Kalender' },
{ href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' },
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
{ href: '/statistik', key: 'statistik', label: 'Statistik' },
];
export function renderHeader(active) {
const header = document.createElement('header');
header.className = '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>
<button type="button" class="nav-toggle" aria-expanded="false" aria-controls="site-nav" aria-label="Menü">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
</button>
<nav id="site-nav">
${NAV.map((n) => `<a href="${n.href}" class="${n.key === active ? 'active' : ''}">${n.label}</a>`).join('')}
</nav>
</div>`;
document.body.prepend(header);
// Auf schmalen Bildschirmen klappt das Menü über die Schaltfläche auf
const toggle = header.querySelector('.nav-toggle');
toggle.addEventListener('click', () => {
const open = header.classList.toggle('open');
toggle.setAttribute('aria-expanded', open);
});
if (!document.querySelector('link[rel="icon"]')) {
document.head.insertAdjacentHTML('beforeend', '<link rel="icon" type="image/png" href="/img/favicon.png">');
}
if (active !== 'login') renderAuthLink(header.querySelector('nav'));
}
// ---------- Fußzeile (öffentliche Seiten) ----------
export function renderFooter() {
const footer = document.createElement('footer');
footer.innerHTML = `
HCRB · Highcap Rasselbande · Berlin
<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>
</div>`;
document.body.append(footer);
}
async function renderAuthLink(nav) {
const { user } = await api('GET', '/me').catch(() => ({}));
if (!user) {
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Abmelden';
btn.title = `Angemeldet als ${user}`;
btn.addEventListener('click', async () => {
await api('POST', '/logout').catch(() => {});
location.href = '/';
});
nav.append(btn);
}
+34
View File
@@ -0,0 +1,34 @@
import { $, api, renderHeader, renderFooter } from './common.js';
renderHeader('login');
renderFooter();
// Nur Pfade auf dieser Seite zulassen (kein Weiterleiten auf fremde Seiten)
function nextUrl() {
const next = new URLSearchParams(location.search).get('next') || '';
return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/kalender';
}
const { user } = await api('GET', '/me');
if (user) location.replace(nextUrl());
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const error = $('#login-error');
const button = e.target.querySelector('button');
error.hidden = true;
button.disabled = true;
try {
await api('POST', '/login', { username: $('#login-user').value, password: $('#login-password').value });
location.replace(nextUrl());
} catch (err) {
error.textContent = err.message;
error.hidden = false;
$('#login-password').value = '';
$('#login-password').focus();
} finally {
button.disabled = false;
}
});
$('#login-user').focus();
+69
View File
@@ -0,0 +1,69 @@
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
renderHeader('statistik');
renderFooter();
let stats = [];
let canEdit = false; // angemeldet und laufendes Jahr
function detail(s) {
if (!s.manual) return '';
const sign = s.manual > 0 ? '+' : '−';
return `<span class="meta">${s.calendar} aus Kalender ${sign} ${Math.abs(s.manual)} manuell</span>`;
}
function render() {
$('#stats-empty').hidden = stats.length > 0;
$('#stats-edit-hint').hidden = !canEdit;
// Gleiche Anzahl → gleicher Platz
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
$('#stats-list').innerHTML = stats.map((s) => `
<li>
<span class="rank">${rank(s)}.</span>
<span class="name">${esc(s.name)}</span>
${detail(s)}
<span class="actions">
${canEdit ? `
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
<span class="stat-total">${s.total}</span>
${canEdit ? `
<button class="small" data-step="1" data-id="${s.id}" aria-label="Ein Spiel mehr">+</button>` : ''}
</span>
</li>`).join('');
}
$('#stats-list').addEventListener('click', async (e) => {
const { step, id } = e.target.dataset;
if (!step) return;
const s = stats.find((x) => x.id === id);
try {
const { manual } = await api('PUT', `/members/${id}/plays-manual`, { manual: Math.max(s.manual, -s.calendar) + Number(step) });
s.manual = manual;
s.total = Math.max(0, s.calendar + manual);
stats.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
render();
} catch (err) {
showError(err);
}
});
let loggedIn = false;
async function load(year) {
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
stats = data.entries;
canEdit = loggedIn && data.live;
$('#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.'
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
render();
}
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
api('GET', '/me')
.then(({ user }) => { loggedIn = !!user; return load(); })
.catch(showError);
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="login">
<div class="card">
<h2>Anmelden</h2>
<form id="login-form">
<label>Benutzername
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
</label>
<label>Passwort
<input type="password" id="login-password" required autocomplete="current-password">
</label>
<p id="login-error" class="warn" hidden></p>
<button type="submit" class="primary">Anmelden</button>
</form>
</div>
</main>
<script type="module" src="/js/login.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Statistik – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div class="card">
<div class="card-head">
<h2>Gespielte Spieltage</h2>
<select id="stats-year" aria-label="Jahr"></select>
</div>
<p id="stats-info" class="muted"></p>
<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>
</main>
<script type="module" src="/js/statistik.js"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
// Benutzerverwaltung für den Login
//
// npm run user -- list
// npm run user -- add <name> Benutzer anlegen (Passwort wird abgefragt)
// npm run user -- passwd <name> Passwort ändern (meldet alle Sitzungen des Benutzers ab)
// npm run user -- remove <name> Benutzer löschen
import { createInterface } from 'node:readline';
import { hashPassword, readUsers, writeUsers, normalizeName, MIN_PASSWORD, USERS_FILE } from '../auth.js';
// Passwort verdeckt einlesen (bei umgeleiteter Eingabe zeilenweise)
let pipedLines;
function askPassword(prompt) {
return new Promise((resolve, reject) => {
const { stdin, stdout } = process;
stdout.write(prompt);
if (!stdin.isTTY) {
pipedLines ??= createInterface({ input: stdin })[Symbol.asyncIterator]();
pipedLines.next().then(({ value }) => { stdout.write('\n'); resolve(value ?? ''); }, reject);
return;
}
let input = '';
stdin.setRawMode(true);
stdin.setEncoding('utf8');
stdin.resume();
const onData = (chunk) => {
for (const ch of chunk) {
if (ch === '\r' || ch === '\n') {
stdin.setRawMode(false);
stdin.pause();
stdin.off('data', onData);
stdout.write('\n');
return resolve(input);
}
if (ch === '\u0003') { // Strg+C
stdin.setRawMode(false);
stdout.write('\n');
return reject(new Error('Abgebrochen.'));
}
if (ch === '\u007f' || ch === '\b') input = input.slice(0, -1);
else input += ch;
}
};
stdin.on('data', onData);
});
}
async function newPassword() {
const pw = await askPassword('Neues Passwort: ');
if (pw.length < MIN_PASSWORD) throw new Error(`Das Passwort muss mindestens ${MIN_PASSWORD} Zeichen haben.`);
if (await askPassword('Passwort wiederholen: ') !== pw) throw new Error('Die Passwörter stimmen nicht überein.');
return hashPassword(pw);
}
const [cmd, rawName] = process.argv.slice(2);
const name = normalizeName(rawName);
const users = readUsers();
const user = users.find((u) => u.name === name);
try {
if (cmd === 'list') {
console.log(users.length ? users.map((u) => u.name).join('\n') : 'Noch keine Benutzer angelegt.');
} else if (cmd === 'add' && name) {
if (!/^[a-z0-9._-]{2,32}$/.test(name)) {
throw new Error('Benutzername: 2–32 Zeichen, nur a–z, 0–9, Punkt, Minus, Unterstrich.');
}
if (user) throw new Error(`Benutzer „${name}“ gibt es schon.`);
users.push({ name, hash: await newPassword(), changedAt: Date.now() });
writeUsers(users);
console.log(`Benutzer „${name}“ angelegt.`);
} else if (cmd === 'passwd' && name) {
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
user.hash = await newPassword();
user.changedAt = Date.now();
writeUsers(users);
console.log(`Passwort von „${name}“ geändert. Bestehende Anmeldungen sind abgemeldet.`);
} else if (cmd === 'remove' && name) {
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
writeUsers(users.filter((u) => u !== user));
console.log(`Benutzer „${name}“ gelöscht.`);
} else {
console.log('Benutzung: npm run user -- list | add <name> | passwd <name> | remove <name>');
console.log(`Benutzerdatei: ${USERS_FILE}`);
process.exitCode = cmd ? 1 : 0;
}
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
+331
View File
@@ -0,0 +1,331 @@
import express from 'express';
import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
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 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: [] };
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 };
}
return {
members,
events: db.events || [],
meetingPoints: db.meetingPoints || [],
statsArchive: db.statsArchive || [],
};
}
function saveDb() {
mkdirSync(DATA_DIR, { recursive: true });
const tmp = DB_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify(db, null, 2));
renameSync(tmp, DB_FILE);
}
const db = loadDb();
// ---------- Validierung ----------
class BadRequest extends Error {}
// memberIds: erlaubte IDs für "fährt am liebsten mit bei", pointIds: erlaubte Standard-Treffpunkte
function cleanMember(body, { memberIds, pointIds = new Set(db.meetingPoints.map((p) => p.id)), selfId }) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Vorname fehlt.');
const hasCar = !!body.hasCar;
const seats = hasCar ? Math.min(8, Math.max(1, parseInt(body.seats, 10) || 1)) : 0;
const alwaysDrives = hasCar && !!body.alwaysDrives;
const ridesWith = memberIds.has(body.ridesWith) && body.ridesWith !== selfId ? body.ridesWith : null;
const meetingPoint = hasCar && pointIds.has(body.meetingPoint) ? body.meetingPoint : null;
const external = !!body.external;
return { name: name.slice(0, 50), external, hasCar, seats, alwaysDrives, meetingPoint, ridesWith };
}
function cleanEvent(body) {
const date = String(body?.date ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequest('Ungültiges Datum.');
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) };
}
function cleanMeetingPoint(body) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Name des Treffpunkts fehlt.');
const address = String(body.address ?? '').trim();
if (!address) throw new BadRequest('Adresse fehlt.');
return { name: name.slice(0, 60), address: address.slice(0, 200) };
}
function cleanPlaysCount(value) {
return Math.min(999, Math.max(-999, parseInt(value, 10) || 0));
}
// Manuelle Korrekturen pro Jahr: { "2026": 2, ... }
function cleanPlaysManual(value) {
const out = {};
if (value && typeof value === 'object') {
for (const [year, n] of Object.entries(value)) {
if (/^\d{4}$/.test(year) && cleanPlaysCount(n)) out[year] = cleanPlaysCount(n);
}
}
return out;
}
function memberIds() {
return new Set(db.members.map((m) => m.id));
}
function findOr404(list, id, res, what) {
const item = list.find((x) => x.id === id);
if (!item) res.status(404).json({ error: `${what} nicht gefunden.` });
return item;
}
// ---------- App ----------
const app = express();
// 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');
app.use(express.json({ limit: '1mb' }));
// Öffentlich: Startseite, Login, CSS/Bilder
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
authRoutes(app);
// Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
app.get('/api/upcoming', (req, res) => {
const today = todayIso();
res.json(db.events
.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 })));
});
// ---------- Jahresstatistik ----------
// Spiele pro Mitglied (ohne Externe) in einem Jahr: Spieltage mit Zusage bis heute + manuelle Korrektur
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;
for (const id of ev.attendees) fromCalendar.set(id, (fromCalendar.get(id) || 0) + 1);
}
return db.members.filter((m) => !m.external).map((m) => {
const calendar = fromCalendar.get(m.id) || 0;
const manual = m.playsManual?.[year] || 0;
return { id: m.id, name: m.name, calendar, manual, total: Math.max(0, calendar + manual) };
}).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
}
// Abgeschlossene Jahre werden einmalig eingefroren; das neue Jahr startet automatisch bei 0
function archiveFinishedYears() {
const current = new Date().getFullYear();
const years = new Set(db.events.map((e) => +e.date.slice(0, 4)));
for (const m of db.members) for (const y of Object.keys(m.playsManual || {})) years.add(+y);
let changed = false;
for (const year of [...years].sort()) {
if (year >= current || db.statsArchive.some((a) => a.year === year)) continue;
db.statsArchive.push({ year, savedAt: new Date().toISOString(), entries: yearStats(year) });
changed = true;
console.log(`Jahresstatistik ${year} archiviert.`);
}
if (changed) {
db.statsArchive.sort((a, b) => b.year - a.year);
saveDb();
}
}
archiveFinishedYears();
setInterval(archiveFinishedYears, 60 * 60 * 1000);
// ?year=2025 → archiviertes Jahr, sonst das laufende Jahr (live)
app.get('/api/stats', (req, res) => {
archiveFinishedYears();
const current = new Date().getFullYear();
const years = [current, ...db.statsArchive.map((a) => a.year)];
const year = parseInt(req.query.year, 10) || current;
if (year === current) return res.json({ year, live: true, years, entries: yearStats(year) });
const archived = db.statsArchive.find((a) => a.year === year);
if (!archived) return res.status(404).json({ error: `Für ${year} gibt es keine Statistik.` });
res.json({ year, live: false, years, savedAt: archived.savedAt, entries: archived.entries });
});
// Ab hier nur mit Login
app.use(requireAuth);
app.use(express.static(join(ROOT, 'private'), {
extensions: ['html'],
setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'),
}));
// Mitglieder
app.get('/api/members', (req, res) => {
res.json(db.members);
});
app.post('/api/members', (req, res) => {
const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) };
db.members.push(member);
saveDb();
res.status(201).json(member);
});
app.put('/api/members/:id', (req, res) => {
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
if (!member) return;
Object.assign(member, cleanMember(req.body, { memberIds: memberIds(), selfId: member.id }));
saveDb();
res.json(member);
});
app.delete('/api/members/:id', (req, res) => {
const { id } = req.params;
db.members = db.members.filter((m) => m.id !== id);
for (const m of db.members) if (m.ridesWith === id) m.ridesWith = null;
for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => a !== id);
saveDb();
res.status(204).end();
});
// Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen)
app.put('/api/members/:id/plays-manual', (req, res) => {
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
if (!member) return;
const year = new Date().getFullYear();
member.playsManual = { ...member.playsManual, [year]: cleanPlaysCount(req.body?.manual) };
saveDb();
res.json({ id: member.id, year, manual: member.playsManual[year] });
});
// Ersetzt die komplette Gruppe (Import aus JSON-Datei)
app.post('/api/members/import', (req, res) => {
if (!Array.isArray(req.body)) throw new BadRequest('Erwarte eine Liste von Mitgliedern.');
const rows = req.body
.filter((m) => String(m?.name ?? '').trim())
.map((m) => ({ ...m, id: typeof m.id === 'string' && m.id ? m.id : randomUUID() }));
const ids = new Set(rows.map((m) => m.id));
const imported = rows.map((m) => ({
id: m.id,
...cleanMember(m, { memberIds: ids, selfId: m.id }),
playsManual: cleanPlaysManual(m.playsManual),
}));
db.members = imported;
for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => ids.has(a));
saveDb();
res.json(db.members);
});
// Treffpunkte
app.get('/api/meeting-points', (req, res) => {
res.json([...db.meetingPoints].sort((a, b) => a.name.localeCompare(b.name, 'de')));
});
app.post('/api/meeting-points', (req, res) => {
const point = { id: randomUUID(), ...cleanMeetingPoint(req.body) };
db.meetingPoints.push(point);
saveDb();
res.status(201).json(point);
});
app.put('/api/meeting-points/:id', (req, res) => {
const point = findOr404(db.meetingPoints, req.params.id, res, 'Treffpunkt');
if (!point) return;
Object.assign(point, cleanMeetingPoint(req.body));
saveDb();
res.json(point);
});
app.delete('/api/meeting-points/:id', (req, res) => {
db.meetingPoints = db.meetingPoints.filter((p) => p.id !== req.params.id);
for (const m of db.members) if (m.meetingPoint === req.params.id) m.meetingPoint = null;
saveDb();
res.status(204).end();
});
// Spieltage
app.get('/api/events', (req, res) => {
res.json([...db.events].sort((a, b) => a.date.localeCompare(b.date)));
});
app.get('/api/events/:id', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (ev) res.json(ev);
});
app.post('/api/events', (req, res) => {
const createdBy = db.members.some((m) => m.id === req.body?.createdBy) ? req.body.createdBy : null;
const ev = {
id: randomUUID(),
...cleanEvent(req.body),
createdBy,
attendees: createdBy ? [createdBy] : [],
};
db.events.push(ev);
saveDb();
res.status(201).json(ev);
});
app.put('/api/events/:id', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
Object.assign(ev, cleanEvent(req.body));
saveDb();
res.json(ev);
});
app.delete('/api/events/:id', (req, res) => {
db.events = db.events.filter((e) => e.id !== req.params.id);
saveDb();
res.status(204).end();
});
// Zusagen / Absagen
app.put('/api/events/:id/attendees/:memberId', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
if (!findOr404(db.members, req.params.memberId, res, 'Mitglied')) return;
if (!ev.attendees.includes(req.params.memberId)) ev.attendees.push(req.params.memberId);
saveDb();
res.json(ev);
});
app.delete('/api/events/:id/attendees/:memberId', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
ev.attendees = ev.attendees.filter((a) => a !== req.params.memberId);
saveDb();
res.json(ev);
});
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Unbekannte Anfrage.' });
});
app.use((err, req, res, next) => {
if (err instanceof BadRequest) return res.status(400).json({ error: err.message });
console.error(err);
res.status(500).json({ error: 'Serverfehler.' });
});
app.listen(PORT, HOST, () => {
console.log(`HCRB läuft auf http://${HOST || 'localhost'}:${PORT}`);
});