commit 10400eee59f59e995bf5644076250d7b1cd9ab48 Author: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Sat Oct 10 13:54:33 2026 +0200 Erste Version: HCRB Spieltag-Kalender und Fahrtplaner Co-Authored-By: Claude Opus 5.5 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 0000000..00d3f26 Binary files /dev/null and b/public/img/favicon.png differ diff --git a/public/img/flagge.jpg b/public/img/flagge.jpg new file mode 100644 index 0000000..5ad44e9 Binary files /dev/null and b/public/img/flagge.jpg differ diff --git a/public/img/logo.png b/public/img/logo.png new file mode 100644 index 0000000..a80e112 Binary files /dev/null and b/public/img/logo.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..741a820 --- /dev/null +++ b/public/index.html @@ -0,0 +1,121 @@ + + + + + + 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}`); +});