From 10400eee59f59e995bf5644076250d7b1cd9ab48 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:54:33 +0200 Subject: [PATCH] Erste Version: HCRB Spieltag-Kalender und Fahrtplaner Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 10 + .env.example | 3 + .gitignore | 3 + DEPLOY.md | 193 ++++++++++ Dockerfile | 20 + README.md | 78 ++++ auth.js | 184 ++++++++++ deploy/backup.sh | 10 + deploy/update-docker.sh | 12 + docker-compose.yml | 22 ++ package-lock.json | 789 ++++++++++++++++++++++++++++++++++++++++ package.json | 19 + private/fahrt.html | 96 +++++ private/gruppe.html | 85 +++++ private/js/fahrt.js | 519 ++++++++++++++++++++++++++ private/js/gruppe.js | 216 +++++++++++ private/js/kalender.js | 182 +++++++++ private/kalender.html | 52 +++ public/css/style.css | 440 ++++++++++++++++++++++ public/img/favicon.png | Bin 0 -> 8161 bytes public/img/flagge.jpg | Bin 0 -> 69556 bytes public/img/logo.png | Bin 0 -> 18848 bytes public/index.html | 121 ++++++ public/js/common.js | 140 +++++++ public/js/login.js | 34 ++ public/js/statistik.js | 69 ++++ public/login.html | 28 ++ public/statistik.html | 26 ++ scripts/user.js | 89 +++++ server.js | 331 +++++++++++++++++ 30 files changed, 3771 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 auth.js create mode 100755 deploy/backup.sh create mode 100755 deploy/update-docker.sh create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 private/fahrt.html create mode 100644 private/gruppe.html create mode 100644 private/js/fahrt.js create mode 100644 private/js/gruppe.js create mode 100644 private/js/kalender.js create mode 100644 private/kalender.html create mode 100644 public/css/style.css create mode 100644 public/img/favicon.png create mode 100644 public/img/flagge.jpg create mode 100644 public/img/logo.png create mode 100644 public/index.html create mode 100644 public/js/common.js create mode 100644 public/js/login.js create mode 100644 public/js/statistik.js create mode 100644 public/login.html create mode 100644 public/statistik.html create mode 100644 scripts/user.js create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e76ded2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +data +.git +.vscode +deploy +DEPLOY.md +Dockerfile +docker-compose.yml +*.log +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..52603e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Nach .env kopieren und den Netzwerknamen des Nginx Proxy Managers eintragen +# (herausfinden mit: docker inspect --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}') +PROXY_NETWORK=npm_default diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..a31e4db --- /dev/null +++ b/DEPLOY.md @@ -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 ` | +| Passwort ändern | `docker compose exec hcrb npm run user -- passwd ` | +| 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. | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0aff0af --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b169131 --- /dev/null +++ b/README.md @@ -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://: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 # Benutzer anlegen +npm run user -- passwd # Passwort ändern (meldet den Benutzer überall ab) +npm run user -- remove # 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/`. diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..2c448be --- /dev/null +++ b/auth.js @@ -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(); +} diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..864ea85 --- /dev/null +++ b/deploy/backup.sh @@ -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 diff --git a/deploy/update-docker.sh b/deploy/update-docker.sh new file mode 100755 index 0000000..cbd6a79 --- /dev/null +++ b/deploy/update-docker.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0ea2f2 --- /dev/null +++ b/docker-compose.yml @@ -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} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a337109 --- /dev/null +++ b/package-lock.json @@ -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==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ffde3cf --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/private/fahrt.html b/private/fahrt.html new file mode 100644 index 0000000..0a65ee2 --- /dev/null +++ b/private/fahrt.html @@ -0,0 +1,96 @@ + + + + + + Fahrtplaner – HCRB + + + +
+ + +
+
+

Fahrt

+ +
+
+ + +
+ + +
+ + +
+
+ +
+
+

Wer kommt mit?

+
+ + +
+
+ +
    +
    + +
    +

    Weitere Personen (nur diese Fahrt)

    +
    + + + + + +
    +
      +
      + +
      +

      Feste Zuteilung (nur diese Fahrt)

      +

      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.

      +
      + + fährt bei + + +
      +
        +
        + +
        +

        Einteilung

        +
        + +

        Wer „fährt immer“ hat und sein Auto dabei hat, fährt trotzdem selbst und bekommt zuerst Mitfahrer.

        +

        Treffpunkt und Zeiten kannst du danach bei jedem Auto einzeln ändern. + Fahrer mit eigenem Standard-Treffpunkt (unter Gruppe) starten automatisch dort.

        +
        + +
        +
        +
        +
        +
        + + + + diff --git a/private/gruppe.html b/private/gruppe.html new file mode 100644 index 0000000..5983501 --- /dev/null +++ b/private/gruppe.html @@ -0,0 +1,85 @@ + + + + + + Gruppe – HCRB + + + +
        +
        +

        Mitglied hinzufügen

        +
        + + + + + + +
        + + +
        +
        +
        + +
        +

        Gruppe

        + +
          +
          + +
          +

          Treffpunkt hinzufügen

          +
          + + + +
          + + +
          +
          + +
            +
            + +
            +

            Daten sichern

            +

            Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).

            +
            + + +
            +
            +
            + + + + diff --git a/private/js/fahrt.js b/private/js/fahrt.js new file mode 100644 index 0000000..a8a728a --- /dev/null +++ b/private/js/fahrt.js @@ -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) => ``).join('') + + '' + : ''; +} + +// "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 ` +
          • + + + ${m.hasCar ? ` + + ${m.alwaysDrives ? 'fährt immer' : ''}` : 'kein Auto'} + +
          • `; + }).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 = '' + + people.map((p) => ``).join(''); + $('#guests-list').innerHTML = trip.guests.map((g) => ` +
          • + ${esc(g.name)} Gast + ${nameOf(g.ridesWith) ? `(gern bei ${esc(nameOf(g.ridesWith))})` : ''} + ${g.hasCar ? `Auto · ${seatsLabel(g.seats)}` : 'kein Auto'} + +
          • `).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) => ``).join('') + : ''; + $('#fixed-driver').innerHTML = drivers.length + ? drivers.map((d) => ``).join('') + : ''; + $('#fixed-form button').disabled = !passengers.length || !drivers.length; + + $('#fixed-list').innerHTML = [...trip.fixed].map(([passengerId, driverId]) => ` +
          • + ${esc(byId.get(passengerId).name)} fährt bei ${esc(byId.get(driverId).name)} + +
          • `).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 ? ' Gast' : '') + + (p.external ? ' Extern' : ''); + +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 = '
            '; + if (point) { + const maps = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(point.address)}`; + html += `
            Treffpunkt
            ${esc(point.name)} + ${esc(point.address)}
            `; + } + if (meet) html += `
            Treffzeit
            ${esc(meet)} Uhr
            `; + if (departure) html += `
            Abfahrt
            ${esc(departure)} Uhr
            `; + return html + '
            '; +} + +// 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 = '

            Bitte wähle mindestens eine Person aus.

            '; + 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) => ``).join('') + + ``; + + let html = heading ? `

            ${esc(heading)}

            ` : ''; + html += '
            '; + html += `

            ${participants.length} Personen · ${plan.cars.length} ${plan.cars.length === 1 ? 'Auto' : 'Autos'} · ${totalSeats} Sitzplätze

            `; + + html += '
            ' + plan.cars.map((car, i) => ` +
            +

            Auto ${i + 1}: ${esc(car.driver.name)}${badges(car.driver)}

            +
            ${car.passengers.length} / ${car.driver.seats} Plätze belegt
            +
            + +
            + + +
            +
            +
            + ${car.passengers.length + ? `
              ${car.passengers.map((p) => `
            1. ${esc(p.name)}${badges(p)}${trip.fixed.get(p.id) === car.driver.id ? ' fest' : ''}
            2. `).join('')}
            ` + : '

            keine Mitfahrer

            '} +
            `).join('') + '
            '; + + if (plan.unassigned.length) { + const missing = plan.unassigned.length; + html += `
            Nicht genug Plätze! Es fehlen ${seatsLabel(missing)}.
            + Ohne Platz: ${plan.unassigned.map((p) => esc(p.name)).join(', ')}
            `; + } + + html += ` +
            + + + +
            `; + + 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: ${esc(ev.title)} 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); diff --git a/private/js/gruppe.js b/private/js/gruppe.js new file mode 100644 index 0000000..463ec1c --- /dev/null +++ b/private/js/gruppe.js @@ -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 = '' + + sortByName(members.filter((m) => m.id !== selfId)) + .map((m) => ``).join(''); +} + +// Auswahl Standard-Treffpunkt +function fillMemberPoint(selected) { + $('#member-point').innerHTML = '' + + points.map((p) => ``).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) => ` +
          • + ${esc(m.name)}${m.external ? ' Extern' : ''} + ${m.hasCar && pointOf(m.meetingPoint) ? `ab ${esc(pointOf(m.meetingPoint))}` : ''} + ${nameOf(m.ridesWith) ? `fährt gern bei ${esc(nameOf(m.ridesWith))} mit` : ''} + ${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'} + + + + +
          • `).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) => ` +
          • + ${esc(p.name)} + ${esc(p.address)} + + + + +
          • `).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); diff --git a/private/js/kalender.js b/private/js/kalender.js new file mode 100644 index 0000000..4a7d5b5 --- /dev/null +++ b/private/js/kalender.js @@ -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 = '' + + sortByName(members).map((m) => + ``).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) => `
            ${d}
            `).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 += ` + `; + 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 ` +
            +

            ${esc(ev.title)}

            +
            ${formatDate(ev.date, true)}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}
            + ${ev.note ? `
            ${esc(ev.note)}
            ` : ''} +
            ${ev.attendees.length} zugesagt:
            +
            + ${attendees.length + ? attendees.map((a) => `${esc(a.name)}`).join('') + : 'noch niemand'} +
            +
            + ${me ? (going + ? `` + : ``) : ''} + Fahrt planen + +
            +
            `; +} + +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('') + : '

            An diesem Tag ist noch kein Spieltag eingetragen.

            '; +} + +function renderUpcoming() { + const upcoming = events.filter((e) => e.date >= todayIso); + $('#upcoming').innerHTML = upcoming.length + ? upcoming.map(eventHtml).join('') + : '

            Keine kommenden Spieltage.

            '; +} + +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); diff --git a/private/kalender.html b/private/kalender.html new file mode 100644 index 0000000..aa6cbdf --- /dev/null +++ b/private/kalender.html @@ -0,0 +1,52 @@ + + + + + + Kalender – HCRB + + + +
            +
            + + +
            + +
            +
            + +

            + +
            +
            +
            +
            + +
            +

            +
            +
            +

            Spieltag an diesem Tag eintragen

            + + + +
            +
            + +
            +

            Kommende Spieltage

            +
            +
            +
            + + + + diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..fd8841b --- /dev/null +++ b/public/css/style.css @@ -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; } +} diff --git a/public/img/favicon.png b/public/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..00d3f26bb6ee68fa2219b2503785274023053635 GIT binary patch literal 8161 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 z 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%0APUL>bigD2VbwiHmqdcuj_%yN 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 W6@h<6;9n8=R|Ni7MBuya{(l1&2DkkH literal 0 HcmV?d00001 diff --git a/public/img/logo.png b/public/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a80e112c94ee82e5496ebc3f65914f1e51d61702 GIT binary patch literal 18848 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 + + + + + HCRB – Highcap Rasselbande + + + +
            +
            +

            HCRB

            +
            Highcap Rasselbande
            +
            +
            + +
            +

            + Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin. + Am häufigsten findet ihr uns auf der Darkzone. +

            + +

            + Diese Seite ist noch in Arbeit. Es kommen noch Änderungen und neue Funktionen dazu. +

            + + + +
            +
            +

            Nächste Spieltage

            + Zum Kalender +
            +

            Lädt …

            +
            + +
            +
            +

            Pile of Shame

            + Zur Statistik +
            +

            Lädt …

            +
            + +
            +

            Über uns

            +

            + Wir sind die Highcap Rasselbande (HCRB), eine Airsoft-Gruppe aus Berlin. + Gespielt wird bei uns vor allem auf der Darkzone – dort sind wir regelmäßig unterwegs. +

            +

            + Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen + gibt es auf YouTube und Instagram: +

            + +
            +
            + + + + + diff --git a/public/js/common.js b/public/js/common.js new file mode 100644 index 0000000..5630acb --- /dev/null +++ b/public/js/common.js @@ -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) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[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 = ` +
            + HCRBHighcap Rasselbande + + +
            `; + 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', ''); + } + 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 + `; + document.body.append(footer); +} + +async function renderAuthLink(nav) { + const { user } = await api('GET', '/me').catch(() => ({})); + if (!user) { + nav.insertAdjacentHTML('beforeend', 'Anmelden'); + 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); +} diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..2e27f7f --- /dev/null +++ b/public/js/login.js @@ -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(); diff --git a/public/js/statistik.js b/public/js/statistik.js new file mode 100644 index 0000000..5461bd8 --- /dev/null +++ b/public/js/statistik.js @@ -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 `${s.calendar} aus Kalender ${sign} ${Math.abs(s.manual)} manuell`; +} + +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) => ` +
          • + ${rank(s)}. + ${esc(s.name)} + ${detail(s)} + + ${canEdit ? ` + ` : ''} + ${s.total} + ${canEdit ? ` + ` : ''} + +
          • `).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) => ``).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); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..6990fac --- /dev/null +++ b/public/login.html @@ -0,0 +1,28 @@ + + + + + + Anmelden – HCRB + + + +
            +
            +

            Anmelden

            +
            + + + + +
            +
            +
            + + + + diff --git a/public/statistik.html b/public/statistik.html new file mode 100644 index 0000000..37cafbe --- /dev/null +++ b/public/statistik.html @@ -0,0 +1,26 @@ + + + + + + Statistik – HCRB + + + +
            +
            +
            +

            Gespielte Spieltage

            + +
            +

            + + +
              +
              +
              + + + + diff --git a/scripts/user.js b/scripts/user.js new file mode 100644 index 0000000..1eeea46 --- /dev/null +++ b/scripts/user.js @@ -0,0 +1,89 @@ +// Benutzerverwaltung für den Login +// +// npm run user -- list +// npm run user -- add Benutzer anlegen (Passwort wird abgefragt) +// npm run user -- passwd Passwort ändern (meldet alle Sitzungen des Benutzers ab) +// npm run user -- remove 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 | passwd | remove '); + console.log(`Benutzerdatei: ${USERS_FILE}`); + process.exitCode = cmd ? 1 : 0; + } +} catch (err) { + console.error(err.message); + process.exitCode = 1; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..b55c9f9 --- /dev/null +++ b/server.js @@ -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}`); +});