mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
Erste Version: HCRB Spieltag-Kalender und Fahrtplaner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
10400eee59
30 files changed
+3771
No files matched your search
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.vscode
|
||||
deploy
|
||||
DEPLOY.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,3 @@
|
||||
# Nach .env kopieren und den Netzwerknamen des Nginx Proxy Managers eintragen
|
||||
# (herausfinden mit: docker inspect <npm-container> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}')
|
||||
PROXY_NETWORK=npm_default
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
@@ -0,0 +1,193 @@
|
||||
# HCRB auf dem eigenen Server betreiben (Docker + Nginx Proxy Manager)
|
||||
|
||||
Annahme: ein Linux-Server mit SSH-Zugang und Docker, auf dem der **Nginx Proxy Manager** (NPM) als Container läuft
|
||||
und die Domains verwaltet. Dazu eine (Sub-)Domain, z. B. `hcrb.example.de`.
|
||||
|
||||
Der Ablauf: Code liegt in einem Git-Repository → Server holt ihn per `git pull` → `docker compose` baut und startet
|
||||
den Container → der Container hängt im selben Docker-Netz wie NPM → NPM leitet die Domain per HTTPS an `hcrb:3000`.
|
||||
Die Daten (`data/`) liegen außerhalb des Containers auf dem Server und bleiben bei Updates erhalten.
|
||||
|
||||
## 1. Lokal: Git-Repository anlegen (einmalig)
|
||||
|
||||
```bash
|
||||
cd ~/Dokumente/Projekte/airsoft_plan_tool
|
||||
git init -b main
|
||||
git add .
|
||||
git commit -m "Erste Version"
|
||||
```
|
||||
|
||||
Dann ein **privates** Repository bei GitHub/GitLab/Codeberg anlegen und hochladen:
|
||||
|
||||
```bash
|
||||
git remote add origin git@github.com:DEIN-NAME/hcrb.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
`data/`, `.env` und `node_modules/` stehen in `.gitignore` – Passwörter und Mitgliederdaten landen nicht im Repository.
|
||||
|
||||
## 2. Domain
|
||||
|
||||
Beim Domain-Anbieter einen **A-Record** (und ggf. AAAA für IPv6) für `hcrb.example.de` auf die IP des Servers setzen
|
||||
(wie bei den anderen Domains, die schon über NPM laufen).
|
||||
|
||||
## 3. Code auf den Server holen (einmalig)
|
||||
|
||||
Per SSH auf dem Server. Zugriff auf das private Repository – einen SSH-Schlüssel erzeugen:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -N "" -f ~/.ssh/hcrb_deploy
|
||||
cat ~/.ssh/hcrb_deploy.pub
|
||||
```
|
||||
|
||||
Den ausgegebenen Schlüssel im Repository unter *Settings → Deploy keys* eintragen (nur Lesezugriff). Dann:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/hcrb && sudo chown $USER: /opt/hcrb
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/hcrb_deploy" git clone git@github.com:DEIN-NAME/hcrb.git /opt/hcrb
|
||||
cd /opt/hcrb
|
||||
git config core.sshCommand "ssh -i ~/.ssh/hcrb_deploy"
|
||||
chmod +x deploy/*.sh
|
||||
|
||||
# Datenordner – der Container läuft als Benutzer "node" (UID 1000) und muss hier schreiben dürfen
|
||||
mkdir -p data && sudo chown 1000:1000 data
|
||||
```
|
||||
|
||||
## 4. Mit dem Netz des Nginx Proxy Managers verbinden
|
||||
|
||||
Der HCRB-Container wird in das Docker-Netz gehängt, in dem NPM schon läuft. Am NPM selbst muss nichts geändert werden.
|
||||
|
||||
Name des NPM-Containers und seines Netzes herausfinden:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}\t{{.Image}}' | grep -i nginx-proxy-manager
|
||||
docker inspect NAME-DES-NPM-CONTAINERS --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||
```
|
||||
|
||||
Die zweite Zeile gibt den Netzwerknamen aus, z. B. `npm_default`. Diesen in die Datei `.env` eintragen:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # PROXY_NETWORK=npm_default (durch den ausgegebenen Namen ersetzen)
|
||||
```
|
||||
|
||||
## 5. Daten übernehmen (optional)
|
||||
|
||||
Wer die bisherigen Mitglieder, Termine und Logins mitnehmen will, kopiert sie vom eigenen Rechner:
|
||||
|
||||
```bash
|
||||
# lokal ausführen
|
||||
scp data/db.json data/users.json DEIN-USER@SERVER:/tmp/
|
||||
# auf dem Server
|
||||
sudo mv /tmp/db.json /tmp/users.json /opt/hcrb/data/
|
||||
sudo chown 1000:1000 /opt/hcrb/data/*.json && sudo chmod 600 /opt/hcrb/data/*.json
|
||||
```
|
||||
|
||||
## 6. Container starten
|
||||
|
||||
```bash
|
||||
cd /opt/hcrb
|
||||
docker compose up -d --build
|
||||
docker compose ps # Status sollte nach ca. 30 Sekunden "Up … (healthy)" zeigen
|
||||
```
|
||||
|
||||
Ohne übernommene Daten jetzt den ersten Login anlegen (Passwort wird verdeckt abgefragt):
|
||||
|
||||
```bash
|
||||
docker compose exec hcrb npm run user -- add elias
|
||||
```
|
||||
|
||||
Prüfen, ob NPM den Container erreicht (sollte `{"user":null}` ausgeben):
|
||||
|
||||
```bash
|
||||
docker exec NAME-DES-NPM-CONTAINERS curl -s http://hcrb:3000/api/me
|
||||
```
|
||||
|
||||
Der Container startet automatisch neu – nach Abstürzen und nach einem Server-Neustart (`restart: unless-stopped`).
|
||||
|
||||
## 7. Domain im Nginx Proxy Manager anlegen
|
||||
|
||||
In der NPM-Weboberfläche: **Hosts → Proxy Hosts → Add Proxy Host**
|
||||
|
||||
Reiter **Details**
|
||||
|
||||
| Feld | Wert |
|
||||
| --- | --- |
|
||||
| Domain Names | `hcrb.example.de` |
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | `hcrb` |
|
||||
| Forward Port | `3000` |
|
||||
| Cache Assets | aus |
|
||||
| Block Common Exploits | an |
|
||||
| Websockets Support | aus (wird nicht gebraucht) |
|
||||
|
||||
Reiter **SSL**
|
||||
|
||||
| Feld | Wert |
|
||||
| --- | --- |
|
||||
| SSL Certificate | Request a new SSL Certificate |
|
||||
| Force SSL | an |
|
||||
| HTTP/2 Support | an |
|
||||
| HSTS Enabled | an |
|
||||
| E-Mail / Agree to Terms | ausfüllen bzw. anhaken |
|
||||
|
||||
Speichern – NPM holt das Zertifikat und verlängert es automatisch. Danach ist die Seite unter
|
||||
`https://hcrb.example.de` erreichbar.
|
||||
|
||||
NPM gibt die echte Besucher-IP und das Protokoll (`X-Forwarded-For`, `X-Forwarded-Proto`) von sich aus weiter –
|
||||
das braucht die Seite für die Sperre nach 10 falschen Passwörtern und für das sichere Login-Cookie.
|
||||
Unter *Advanced* muss dafür nichts eingetragen werden.
|
||||
|
||||
**Hinweis:** Port 3000 wird bewusst **nicht** nach außen freigegeben – die Seite ist nur über NPM erreichbar.
|
||||
Die NPM-Oberfläche (Port 81) sollte ebenfalls nicht offen im Internet stehen.
|
||||
|
||||
## 8. Backups
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
# Zeile hinzufügen:
|
||||
30 3 * * * /opt/hcrb/deploy/backup.sh
|
||||
```
|
||||
|
||||
Sichert täglich um 3:30 Uhr `data/` nach `/var/backups/hcrb/` und behält 30 Tage.
|
||||
Am besten zusätzlich ab und zu eine Sicherung vom Server herunterladen.
|
||||
|
||||
---
|
||||
|
||||
## Änderungen aufspielen (jedes Mal)
|
||||
|
||||
```bash
|
||||
# lokal
|
||||
git add .
|
||||
git commit -m "Was geändert wurde"
|
||||
git push
|
||||
|
||||
# auf dem Server
|
||||
ssh DEIN-USER@SERVER /opt/hcrb/deploy/update-docker.sh
|
||||
```
|
||||
|
||||
`update-docker.sh` holt den neuen Stand, baut den Container neu und startet ihn. Die Daten bleiben unverändert,
|
||||
an NPM muss nichts geändert werden.
|
||||
|
||||
## Nützliche Befehle auf dem Server
|
||||
|
||||
Alle im Ordner `/opt/hcrb` ausführen:
|
||||
|
||||
| Zweck | Befehl |
|
||||
| --- | --- |
|
||||
| Status | `docker compose ps` |
|
||||
| Log ansehen | `docker compose logs -f` |
|
||||
| Neu starten | `docker compose restart` |
|
||||
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
|
||||
| Benutzer anlegen | `docker compose exec hcrb npm run user -- add <name>` |
|
||||
| Passwort ändern | `docker compose exec hcrb npm run user -- passwd <name>` |
|
||||
| Benutzer auflisten | `docker compose exec hcrb npm run user -- list` |
|
||||
| Backup zurückspielen | `docker compose down && sudo tar -xzf /var/backups/hcrb/hcrb-DATUM.tar.gz -C /opt/hcrb && sudo chown -R 1000:1000 data && docker compose up -d` |
|
||||
|
||||
## Fehlersuche
|
||||
|
||||
| Problem | Ursache / Lösung |
|
||||
| --- | --- |
|
||||
| `network … declared as external, but could not be found` | Netzwerkname in `.env` stimmt nicht – Schritt 4 wiederholen. |
|
||||
| NPM zeigt „502 Bad Gateway“ | Container läuft nicht (`docker compose ps`, `docker compose logs`) oder hängt nicht im NPM-Netz (Schritt 4). |
|
||||
| Container startet neu mit `EACCES … data` | Rechte am Datenordner: `sudo chown -R 1000:1000 /opt/hcrb/data` |
|
||||
| Nach 10 falschen Passwörtern ist der Login für **alle** gesperrt | Die Seite erkennt die echte Besucher-IP nicht – prüfen, ob `TRUST_PROXY` in `docker-compose.yml` gesetzt ist und der Zugriff über NPM erfolgt. |
|
||||
+20
@@ -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"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# HCRB – Highcap Rasselbande
|
||||
|
||||
Webseite der Airsoft-Gruppe HCRB mit Spieltag-Kalender und Fahrtplaner.
|
||||
|
||||
## Starten
|
||||
|
||||
```bash
|
||||
npm install # nur beim ersten Mal
|
||||
npm start # oder: npm run dev (startet bei Änderungen neu)
|
||||
```
|
||||
|
||||
Vor dem ersten Login einen Benutzer anlegen (Passwort wird verdeckt abgefragt):
|
||||
|
||||
```bash
|
||||
npm run user -- add elias
|
||||
```
|
||||
|
||||
Dann im Browser `http://localhost:3000` öffnen.
|
||||
Andere Geräte im selben WLAN erreichen die Seite über `http://<IP-dieses-Rechners>:3000`.
|
||||
Anderen Port wählen: `PORT=8080 npm start`.
|
||||
|
||||
## Seiten
|
||||
|
||||
- **Start** (`/`) – Startseite mit Platzhaltertext, Links zu Kalender und Fahrtplaner, nächste Spieltage.
|
||||
- **Kalender** (`/kalender`) – Mitglieder wählen „Ich bin …“, tragen Spieltage ein und sagen zu oder ab.
|
||||
Über „Fahrt planen“ geht es direkt in den Fahrtplaner, alle Zusagen sind dann schon als „kommt mit“ ausgewählt.
|
||||
- **Fahrtplaner** (`/fahrt`) – Treffpunkt, Abfahrt (Standard 07:00) und Treffzeit (15 min vorher) festlegen – nach der Berechnung pro Auto änderbar, Mitfahrer auswählen
|
||||
und pro Fahrt angeben, wer sein Auto dabei hat. Wer immer mit eigenem Auto kommt, fährt immer selbst und bekommt zuerst Mitfahrer.
|
||||
Wer gern bei jemandem mitfährt, kommt in dessen Auto (oder ins selbe Auto, falls beide mitfahren).
|
||||
Unter „Feste Zuteilung“ lässt sich vorab festlegen, wer diesmal sicher bei wem mitfährt, Gäste nur für diese Fahrt hinzufügen, Fahrgemeinschaften berechnen,
|
||||
Ergebnis kopieren oder drucken. „Eingabe zurücksetzen“ leert alles für die nächste Planung.
|
||||
- **Statistik** (`/statistik`, öffentlich) – wie oft jedes Mitglied (ohne Externe) im laufenden Jahr gespielt hat:
|
||||
Spieltage mit Zusage aus dem Kalender plus manuelle Korrektur (− / +, nur angemeldet), z. B. für spontane Fahrten.
|
||||
Zu Neujahr wird der Endstand des Vorjahres archiviert (im Jahresmenü abrufbar) und die Zählung beginnt bei 0.
|
||||
Die Startseite zeigt als „Pile of Shame“, wer im laufenden Jahr noch nicht spielen war.
|
||||
Der Jahreswechsel richtet sich nach der Zeitzone des Servers (im systemd-Dienst `Environment=TZ=Europe/Berlin` setzen).
|
||||
- **Gruppe** (`/gruppe`) – feste Mitglieder (Vorname, extern ja/nein, Auto ja/nein, freie Plätze ohne Fahrer, „kommt immer mit eigenem Auto“,
|
||||
Standard-Treffpunkt als Fahrer, „fährt am liebsten mit bei …“) und Treffpunkte (Name, Adresse) verwalten, Export/Import der Mitglieder als JSON.
|
||||
|
||||
## Login
|
||||
|
||||
Öffentlich sind nur die Startseite (inkl. der nächsten 3 Spieltage: Datum, Titel, Anzahl Zusagen) und die Statistik
|
||||
(Namen und Anzahl gespielter Spieltage).
|
||||
Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar.
|
||||
|
||||
Benutzer werden auf dem Server per Kommandozeile verwaltet:
|
||||
|
||||
```bash
|
||||
npm run user -- list # alle Benutzer anzeigen
|
||||
npm run user -- add <name> # Benutzer anlegen
|
||||
npm run user -- passwd <name> # Passwort ändern (meldet den Benutzer überall ab)
|
||||
npm run user -- remove <name> # Benutzer löschen (sofort abgemeldet)
|
||||
```
|
||||
|
||||
Sicherheit:
|
||||
|
||||
- Passwörter werden nur als scrypt-Hash mit eigenem Salt gespeichert (`data/users.json`), nie im Klartext.
|
||||
- Sitzungen gelten 30 Tage. Im Browser liegt ein zufälliges Token als `HttpOnly`-Cookie,
|
||||
auf dem Server nur dessen SHA-256-Hash (`data/sessions.json`).
|
||||
- Nach 10 Fehlversuchen innerhalb von 15 Minuten wird die IP für den Login gesperrt.
|
||||
- Im Internet nur über HTTPS betreiben (z. B. hinter nginx), dann wird das Cookie automatisch als `Secure` gesetzt.
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points)
|
||||
auth.js Login, Passwort-Hashing, Sitzungen
|
||||
scripts/user.js Benutzerverwaltung (npm run user)
|
||||
data/ db.json, users.json, sessions.json (automatisch angelegt, nicht in Git)
|
||||
public/ öffentlich: Startseite, Login, CSS, Bilder, common.js
|
||||
private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe
|
||||
```
|
||||
|
||||
## Auf einem Server betreiben
|
||||
|
||||
Per Docker hinter dem Nginx Proxy Manager: `docker compose up -d --build` (Daten liegen in `./data`,
|
||||
das Docker-Netz des Proxys steht in `.env`). Schritt-für-Schritt-Anleitung inkl. Domain, HTTPS, Updates und Backups:
|
||||
[DEPLOY.md](DEPLOY.md). Skripte für Updates und Backups liegen in `deploy/`.
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+10
@@ -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
|
||||
Executable
+12
@@ -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
|
||||
@@ -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}
|
||||
Generated
+789
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fahrtplaner – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<div id="from-event" class="notice no-print" hidden></div>
|
||||
|
||||
<div class="card no-print">
|
||||
<div class="card-head">
|
||||
<h2>Fahrt</h2>
|
||||
<button type="button" id="reset-btn" class="small">Eingabe zurücksetzen</button>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label>Ziel / Event
|
||||
<input type="text" id="trip-title" placeholder="z. B. Spieltag Waldfeld">
|
||||
</label>
|
||||
<label>Datum
|
||||
<input type="date" id="trip-date">
|
||||
</label>
|
||||
</div>
|
||||
<label>Treffpunkt (Standard)
|
||||
<select id="trip-point"></select>
|
||||
</label>
|
||||
<p id="trip-points-empty" class="muted" hidden>Treffpunkte legst du unter <a href="/gruppe">Gruppe</a> an.</p>
|
||||
<div class="grid2">
|
||||
<label>Abfahrt
|
||||
<input type="time" id="trip-departure" value="07:00" required>
|
||||
</label>
|
||||
<label>Treffzeit
|
||||
<input type="time" id="trip-meet" value="06:45" required>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card no-print">
|
||||
<div class="card-head">
|
||||
<h2>Wer kommt mit?</h2>
|
||||
<div class="row">
|
||||
<button type="button" id="select-all" class="small">Alle</button>
|
||||
<button type="button" id="select-none" class="small">Keine</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
|
||||
<ul id="trip-members" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card no-print">
|
||||
<h2>Weitere Personen (nur diese Fahrt)</h2>
|
||||
<form id="guest-form" class="row wrap">
|
||||
<input type="text" id="guest-name" placeholder="Vorname" required autocomplete="off">
|
||||
<label class="inline"><input type="checkbox" id="guest-hascar"> Hat Auto</label>
|
||||
<input type="number" id="guest-seats" min="1" max="8" value="4" title="Freie Plätze (ohne Fahrer)" hidden>
|
||||
<select id="guest-rideswith" title="Fährt am liebsten mit bei"></select>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
<ul id="guests-list" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card no-print">
|
||||
<h2>Feste Zuteilung (nur diese Fahrt)</h2>
|
||||
<p class="muted">Wer hier eingetragen ist, fährt sicher bei diesem Fahrer mit – der Fahrer fährt dann auf jeden Fall.
|
||||
Alle anderen werden bei der Berechnung automatisch verteilt.</p>
|
||||
<form id="fixed-form" class="row wrap">
|
||||
<select id="fixed-passenger" title="Wer"></select>
|
||||
<span class="muted">fährt bei</span>
|
||||
<select id="fixed-driver" title="Fahrer"></select>
|
||||
<button type="submit">Festlegen</button>
|
||||
</form>
|
||||
<ul id="fixed-list" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="no-print">Einteilung</h2>
|
||||
<div class="no-print">
|
||||
<label class="inline">
|
||||
<input type="checkbox" id="opt-min-cars" checked> Möglichst wenige Autos verwenden
|
||||
</label>
|
||||
<p class="muted">Wer „fährt immer“ hat und sein Auto dabei hat, fährt trotzdem selbst und bekommt zuerst Mitfahrer.</p>
|
||||
<p class="muted">Treffpunkt und Zeiten kannst du danach bei jedem Auto einzeln ändern.
|
||||
Fahrer mit eigenem Standard-Treffpunkt (unter Gruppe) starten automatisch dort.</p>
|
||||
<div class="row">
|
||||
<button id="plan-btn" class="primary">Fahrgemeinschaften berechnen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/fahrt.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Gruppe – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<div class="card">
|
||||
<h2 id="member-form-title">Mitglied hinzufügen</h2>
|
||||
<form id="member-form">
|
||||
<input type="hidden" id="member-id">
|
||||
<label>Vorname
|
||||
<input type="text" id="member-name" required autocomplete="off" maxlength="50">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="checkbox" id="member-external"> Extern (gehört nicht zur Gruppe, fährt aber manchmal mit)
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="checkbox" id="member-hascar"> Hat Auto
|
||||
</label>
|
||||
<label>Fährt am liebsten mit bei
|
||||
<select id="member-rideswith"></select>
|
||||
</label>
|
||||
<div id="member-seats-wrap" hidden>
|
||||
<label>Freie Plätze (ohne Fahrer)
|
||||
<input type="number" id="member-seats" min="1" max="8" value="4">
|
||||
</label>
|
||||
<label class="inline">
|
||||
<input type="checkbox" id="member-always"> Kommt immer mit eigenem Auto
|
||||
</label>
|
||||
<p class="muted">Fährt dann bei jeder Fahrt selbst, auch wenn weniger Autos reichen würden.</p>
|
||||
<label>Standard-Treffpunkt, wenn er/sie fährt
|
||||
<select id="member-point"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="submit" class="primary">Speichern</button>
|
||||
<button type="button" id="member-cancel" hidden>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Gruppe</h2>
|
||||
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
|
||||
<ul id="members-list" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 id="point-form-title">Treffpunkt hinzufügen</h2>
|
||||
<form id="point-form">
|
||||
<input type="hidden" id="point-id">
|
||||
<label>Name
|
||||
<input type="text" id="point-name" required autocomplete="off" maxlength="60" placeholder="z. B. Parkplatz Bahnhof">
|
||||
</label>
|
||||
<label>Adresse
|
||||
<input type="text" id="point-address" required autocomplete="off" maxlength="200" placeholder="Straße Nr., PLZ Ort">
|
||||
</label>
|
||||
<div class="row">
|
||||
<button type="submit" class="primary">Speichern</button>
|
||||
<button type="button" id="point-cancel" hidden>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
|
||||
<ul id="points-list" class="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Daten sichern</h2>
|
||||
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
|
||||
<div class="row">
|
||||
<button id="export-btn">Exportieren (JSON)</button>
|
||||
<label class="button">Importieren
|
||||
<input type="file" id="import-file" accept="application/json" hidden>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/gruppe.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
$, api, esc, sortByName, seatsLabel, showError, renderHeader, formatDate,
|
||||
} from './common.js';
|
||||
|
||||
renderHeader('fahrt');
|
||||
|
||||
let members = [];
|
||||
let points = [];
|
||||
|
||||
const DEFAULT_DEPARTURE = '07:00';
|
||||
const MEET_BEFORE_MIN = 15;
|
||||
|
||||
// Zustand der aktuellen Fahrt (nur im Browser, wird nicht gespeichert)
|
||||
const trip = {
|
||||
selected: new Set(), // IDs der mitfahrenden Mitglieder
|
||||
bringsCar: new Map(), // ID -> bool, ob das Auto diesmal dabei ist
|
||||
guests: [], // { id, name, hasCar, seats, guest: true }
|
||||
fixed: new Map(), // Mitfahrer-ID -> Fahrer-ID, feste Zuteilung für diese Fahrt
|
||||
};
|
||||
|
||||
let guestCounter = 0;
|
||||
|
||||
// ---------- Treffpunkt und Uhrzeiten ----------
|
||||
function renderPoints() {
|
||||
$('#trip-points-empty').hidden = points.length > 0;
|
||||
$('#trip-point').innerHTML = points.length
|
||||
? points.map((p) => `<option value="${p.id}">${esc(p.name)} – ${esc(p.address)}</option>`).join('')
|
||||
+ '<option value="">(kein Treffpunkt)</option>'
|
||||
: '<option value="">(noch keine Treffpunkte angelegt)</option>';
|
||||
}
|
||||
|
||||
// "07:00" minus 15 Minuten → "06:45" (auch über Mitternacht)
|
||||
function minusMinutes(time, min) {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const total = (((h * 60 + m - min) % 1440) + 1440) % 1440;
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function setDeparture(time) {
|
||||
$('#trip-departure').value = time;
|
||||
$('#trip-meet').value = minusMinutes(time, MEET_BEFORE_MIN);
|
||||
}
|
||||
|
||||
// Treffzeit folgt der Abfahrt, kann danach aber noch von Hand angepasst werden
|
||||
$('#trip-departure').addEventListener('change', (e) => {
|
||||
if (e.target.value) setDeparture(e.target.value);
|
||||
});
|
||||
|
||||
// ---------- Mitglieder auswählen ----------
|
||||
function renderTripMembers() {
|
||||
$('#trip-members-empty').hidden = members.length > 0;
|
||||
const nameOf = (id) => members.find((m) => m.id === id)?.name;
|
||||
$('#trip-members').innerHTML = sortByName(members).map((m) => {
|
||||
if (!trip.bringsCar.has(m.id)) trip.bringsCar.set(m.id, m.hasCar);
|
||||
const checked = trip.selected.has(m.id);
|
||||
return `
|
||||
<li>
|
||||
<label class="inline name">
|
||||
<input type="checkbox" data-select="${m.id}" ${checked ? 'checked' : ''}> ${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}
|
||||
${nameOf(m.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(m.ridesWith))})</span>` : ''}
|
||||
</label>
|
||||
<span class="actions">
|
||||
${m.hasCar ? `
|
||||
<label class="inline meta">
|
||||
<input type="checkbox" data-car="${m.id}" ${trip.bringsCar.get(m.id) ? 'checked' : ''} ${checked ? '' : 'disabled'}>
|
||||
Auto dabei (${seatsLabel(m.seats)})
|
||||
</label>
|
||||
${m.alwaysDrives ? '<span class="badge" title="Kommt immer mit eigenem Auto">fährt immer</span>' : ''}` : '<span class="meta">kein Auto</span>'}
|
||||
</span>
|
||||
</li>`;
|
||||
}).join('');
|
||||
renderFixed();
|
||||
}
|
||||
|
||||
$('#trip-members').addEventListener('change', (e) => {
|
||||
const { select, car } = e.target.dataset;
|
||||
if (select) {
|
||||
e.target.checked ? trip.selected.add(select) : trip.selected.delete(select);
|
||||
renderTripMembers();
|
||||
}
|
||||
if (car) {
|
||||
trip.bringsCar.set(car, e.target.checked);
|
||||
renderFixed();
|
||||
}
|
||||
});
|
||||
|
||||
$('#select-all').addEventListener('click', () => {
|
||||
members.forEach((m) => trip.selected.add(m.id));
|
||||
renderTripMembers();
|
||||
});
|
||||
$('#select-none').addEventListener('click', () => {
|
||||
trip.selected.clear();
|
||||
renderTripMembers();
|
||||
});
|
||||
|
||||
// ---------- Gäste ----------
|
||||
$('#guest-hascar').addEventListener('change', (e) => {
|
||||
$('#guest-seats').hidden = !e.target.checked;
|
||||
});
|
||||
|
||||
$('#guest-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('#guest-name').value.trim();
|
||||
if (!name) return;
|
||||
const hasCar = $('#guest-hascar').checked;
|
||||
trip.guests.push({
|
||||
id: 'guest-' + ++guestCounter,
|
||||
name,
|
||||
hasCar,
|
||||
seats: hasCar ? Math.max(1, parseInt($('#guest-seats').value, 10) || 1) : 0,
|
||||
ridesWith: $('#guest-rideswith').value || null,
|
||||
guest: true,
|
||||
});
|
||||
e.target.reset();
|
||||
$('#guest-seats').hidden = true;
|
||||
renderGuests();
|
||||
$('#guest-name').focus();
|
||||
});
|
||||
|
||||
function renderGuests() {
|
||||
const people = [...sortByName(members), ...trip.guests];
|
||||
const nameOf = (id) => people.find((p) => p.id === id)?.name;
|
||||
$('#guest-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
|
||||
+ people.map((p) => `<option value="${p.id}">gern bei ${esc(p.name)}</option>`).join('');
|
||||
$('#guests-list').innerHTML = trip.guests.map((g) => `
|
||||
<li>
|
||||
<span class="name">${esc(g.name)} <span class="badge">Gast</span>
|
||||
${nameOf(g.ridesWith) ? `<span class="meta">(gern bei ${esc(nameOf(g.ridesWith))})</span>` : ''}</span>
|
||||
<span class="meta">${g.hasCar ? `Auto · ${seatsLabel(g.seats)}` : 'kein Auto'}</span>
|
||||
<button class="link danger" data-remove="${g.id}">Entfernen</button>
|
||||
</li>`).join('');
|
||||
renderFixed();
|
||||
}
|
||||
|
||||
$('#guests-list').addEventListener('click', (e) => {
|
||||
const id = e.target.dataset.remove;
|
||||
if (!id) return;
|
||||
trip.guests = trip.guests.filter((g) => g.id !== id);
|
||||
for (const g of trip.guests) if (g.ridesWith === id) g.ridesWith = null;
|
||||
renderGuests();
|
||||
});
|
||||
|
||||
// ---------- Feste Zuteilung ----------
|
||||
// Alle, die diesmal mitfahren (Mitglieder mit Auto-Häkchen der Fahrt, plus Gäste)
|
||||
function currentParticipants() {
|
||||
return [
|
||||
...sortByName(members.filter((m) => trip.selected.has(m.id))).map((m) => ({
|
||||
...m,
|
||||
hasCar: m.hasCar && trip.bringsCar.get(m.id) !== false,
|
||||
})),
|
||||
...trip.guests,
|
||||
];
|
||||
}
|
||||
|
||||
function renderFixed() {
|
||||
const people = currentParticipants();
|
||||
const byId = new Map(people.map((p) => [p.id, p]));
|
||||
const canDrive = (p) => p?.hasCar && p.seats > 0;
|
||||
|
||||
// Einträge entfernen, die nicht mehr passen (abgewählt, Auto nicht dabei, Auto voll)
|
||||
const used = new Map();
|
||||
for (const [passengerId, driverId] of trip.fixed) {
|
||||
const n = (used.get(driverId) || 0) + 1;
|
||||
if (!byId.has(passengerId) || !canDrive(byId.get(driverId)) || trip.fixed.has(driverId)
|
||||
|| n > byId.get(driverId).seats) {
|
||||
trip.fixed.delete(passengerId);
|
||||
} else {
|
||||
used.set(driverId, n);
|
||||
}
|
||||
}
|
||||
|
||||
const fixedDrivers = new Set(trip.fixed.values());
|
||||
const passengers = people.filter((p) => !trip.fixed.has(p.id) && !fixedDrivers.has(p.id));
|
||||
const drivers = people.filter((p) => canDrive(p) && !trip.fixed.has(p.id) && (used.get(p.id) || 0) < p.seats);
|
||||
|
||||
$('#fixed-passenger').innerHTML = passengers.length
|
||||
? passengers.map((p) => `<option value="${p.id}">${esc(p.name)}</option>`).join('')
|
||||
: '<option value="">(niemand mehr frei)</option>';
|
||||
$('#fixed-driver').innerHTML = drivers.length
|
||||
? drivers.map((d) => `<option value="${d.id}">${esc(d.name)} (${d.seats - (used.get(d.id) || 0)} frei)</option>`).join('')
|
||||
: '<option value="">(kein Auto dabei)</option>';
|
||||
$('#fixed-form button').disabled = !passengers.length || !drivers.length;
|
||||
|
||||
$('#fixed-list').innerHTML = [...trip.fixed].map(([passengerId, driverId]) => `
|
||||
<li>
|
||||
<span class="name">${esc(byId.get(passengerId).name)} <span class="muted">fährt bei</span> ${esc(byId.get(driverId).name)}</span>
|
||||
<button class="link danger" data-unfix="${passengerId}">Entfernen</button>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#fixed-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const passengerId = $('#fixed-passenger').value;
|
||||
const driverId = $('#fixed-driver').value;
|
||||
if (!passengerId || !driverId || passengerId === driverId) return;
|
||||
trip.fixed.set(passengerId, driverId);
|
||||
renderFixed();
|
||||
});
|
||||
|
||||
$('#fixed-list').addEventListener('click', (e) => {
|
||||
const id = e.target.dataset.unfix;
|
||||
if (!id) return;
|
||||
trip.fixed.delete(id);
|
||||
renderFixed();
|
||||
});
|
||||
|
||||
// ---------- Zurücksetzen ----------
|
||||
function resetTrip() {
|
||||
trip.selected.clear();
|
||||
trip.bringsCar.clear();
|
||||
trip.guests = [];
|
||||
trip.fixed.clear();
|
||||
$('#trip-title').value = '';
|
||||
$('#trip-date').value = '';
|
||||
$('#trip-point').selectedIndex = 0;
|
||||
setDeparture(DEFAULT_DEPARTURE);
|
||||
$('#opt-min-cars').checked = true;
|
||||
$('#guest-form').reset();
|
||||
$('#guest-seats').hidden = true;
|
||||
$('#result').innerHTML = '';
|
||||
$('#from-event').hidden = true;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
renderTripMembers();
|
||||
renderGuests();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
$('#reset-btn').addEventListener('click', resetTrip);
|
||||
|
||||
// ---------- Einteilung berechnen ----------
|
||||
/**
|
||||
* Verteilt die Teilnehmer auf Autos.
|
||||
* - Feste Zuteilung (fixed: Mitfahrer-ID -> Fahrer-ID): der Fahrer fährt sicher, der Mitfahrer sitzt sicher bei ihm.
|
||||
* - Wer immer mit eigenem Auto kommt (alwaysDrives) und es dabeihat, fährt auf jeden Fall.
|
||||
* - minCars: weitere Autos nur so viele wie nötig (größte zuerst), sonst alle Autos.
|
||||
* Wer lieber bei einem anderen Autobesitzer mitfährt (ridesWith), wird erst zuletzt Fahrer.
|
||||
* - Wunsch-Mitfahrer: wer bei einem Fahrer mitfahren möchte, kommt zuerst in dessen Auto;
|
||||
* Mitfahrer mit Vorliebe füreinander kommen nach Möglichkeit ins selbe Auto.
|
||||
* - Übrige Mitfahrer: zuerst bekommt jedes Auto jemanden (Immer-Fahrer zuerst), danach gleichmäßig auffüllen.
|
||||
*/
|
||||
function planTrip(participants, minCars, fixed = new Map()) {
|
||||
const byId = new Map(participants.map((p) => [p.id, p]));
|
||||
const hasUsableCar = (p) => !!(p?.hasCar && p.seats > 0);
|
||||
const prefersOtherCar = (p) => hasUsableCar(byId.get(p.ridesWith));
|
||||
const fixedDrivers = new Set(fixed.values());
|
||||
const mustDrive = (p) => p.alwaysDrives || fixedDrivers.has(p.id);
|
||||
|
||||
// Fest zugeteilte Mitfahrer fahren nie selbst
|
||||
const carOwners = participants.filter((p) => hasUsableCar(p) && !fixed.has(p.id));
|
||||
const forced = carOwners.filter(mustDrive).sort((a, b) => b.seats - a.seats);
|
||||
const optional = carOwners.filter((p) => !mustDrive(p))
|
||||
.sort((a, b) => prefersOtherCar(a) - prefersOtherCar(b) || b.seats - a.seats);
|
||||
|
||||
let drivers = [...forced, ...optional];
|
||||
if (minCars) {
|
||||
drivers = [...forced];
|
||||
let capacity = forced.reduce((sum, d) => sum + 1 + d.seats, 0);
|
||||
for (const owner of optional) {
|
||||
if (capacity >= participants.length) break;
|
||||
drivers.push(owner);
|
||||
capacity += 1 + owner.seats;
|
||||
}
|
||||
}
|
||||
|
||||
const driverIds = new Set(drivers.map((d) => d.id));
|
||||
const cars = drivers.map((d) => ({ driver: d, passengers: [] }));
|
||||
const carOf = new Map(cars.map((c) => [c.driver.id, c]));
|
||||
const unassigned = [];
|
||||
const free = (car) => car.driver.seats - car.passengers.length;
|
||||
const place = (p, car) => { car.passengers.push(p); carOf.set(p.id, car); };
|
||||
|
||||
// Auto mit den wenigsten Mitfahrern, bei Gleichstand Immer-Fahrer, dann die meisten freien Plätze
|
||||
function pickCar(size) {
|
||||
let best = null;
|
||||
for (const car of cars) {
|
||||
if (free(car) < size) continue;
|
||||
if (!best
|
||||
|| car.passengers.length < best.passengers.length
|
||||
|| (car.passengers.length === best.passengers.length
|
||||
&& (car.driver.alwaysDrives && !best.driver.alwaysDrives
|
||||
|| (!!car.driver.alwaysDrives === !!best.driver.alwaysDrives && free(car) > free(best))))) {
|
||||
best = car;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// 0. Feste Zuteilung für diese Fahrt
|
||||
for (const p of participants) {
|
||||
const car = carOf.get(fixed.get(p.id));
|
||||
if (car && car.driver.id === fixed.get(p.id) && free(car) > 0) place(p, car);
|
||||
}
|
||||
|
||||
// 1. Wer bei einem Fahrer mitfahren möchte, kommt in dessen Auto
|
||||
let rest = [];
|
||||
for (const p of participants) {
|
||||
if (carOf.has(p.id)) continue;
|
||||
const car = carOf.get(p.ridesWith);
|
||||
if (car && car.driver.id === p.ridesWith && free(car) > 0) place(p, car);
|
||||
else rest.push(p);
|
||||
}
|
||||
|
||||
// 2. Übrige Mitfahrer in Gruppen zusammenfassen, die über Vorlieben verbunden sind
|
||||
const groupOf = new Map(rest.map((p) => [p.id, [p]]));
|
||||
for (const p of rest) {
|
||||
const a = groupOf.get(p.id);
|
||||
const b = groupOf.get(p.ridesWith);
|
||||
if (!b || a === b) continue;
|
||||
a.push(...b);
|
||||
for (const q of b) groupOf.set(q.id, a);
|
||||
}
|
||||
const groups = [...new Set(groupOf.values())].sort((a, b) => b.length - a.length);
|
||||
|
||||
// 3. Gruppen verteilen – bevorzugt ins Auto, in dem die Wunschperson schon sitzt
|
||||
for (const group of groups) {
|
||||
const wished = group.map((p) => carOf.get(p.ridesWith)).find((c) => c && free(c) >= group.length);
|
||||
const car = wished || pickCar(group.length);
|
||||
if (car) {
|
||||
group.forEach((p) => place(p, car));
|
||||
continue;
|
||||
}
|
||||
for (const p of group) {
|
||||
const own = carOf.get(p.ridesWith);
|
||||
const c = own && free(own) > 0 ? own : pickCar(1);
|
||||
c ? place(p, c) : unassigned.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return { cars, unassigned };
|
||||
}
|
||||
|
||||
// ---------- Ergebnis ----------
|
||||
const badges = (p) => (p.guest ? ' <span class="badge">Gast</span>' : '')
|
||||
+ (p.external ? ' <span class="badge">Extern</span>' : '');
|
||||
|
||||
const sameMeeting = (a, b) => a.pointId === b.pointId && a.meet === b.meet && a.departure === b.departure;
|
||||
|
||||
function meetingLines({ pointId, meet, departure }) {
|
||||
const point = points.find((p) => p.id === pointId);
|
||||
return [
|
||||
point && `Treffpunkt: ${point.name}, ${point.address}`,
|
||||
meet && `Treffzeit: ${meet} Uhr`,
|
||||
departure && `Abfahrt: ${departure} Uhr`,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function meetingHtml({ pointId, meet, departure }) {
|
||||
const point = points.find((p) => p.id === pointId);
|
||||
let html = '<dl class="meeting">';
|
||||
if (point) {
|
||||
const maps = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(point.address)}`;
|
||||
html += `<dt>Treffpunkt</dt><dd>${esc(point.name)}
|
||||
<small><a href="${maps}" target="_blank" rel="noopener">${esc(point.address)}</a></small></dd>`;
|
||||
}
|
||||
if (meet) html += `<dt>Treffzeit</dt><dd>${esc(meet)} Uhr</dd>`;
|
||||
if (departure) html += `<dt>Abfahrt</dt><dd>${esc(departure)} Uhr</dd>`;
|
||||
return html + '</dl>';
|
||||
}
|
||||
|
||||
// Haben alle Autos denselben Treffpunkt und dieselben Zeiten, steht das einmal oben, sonst bei jedem Auto
|
||||
function commonMeeting(carMeet, defaults) {
|
||||
if (!carMeet.length) return defaults;
|
||||
return carMeet.every((m) => sameMeeting(m, carMeet[0])) ? carMeet[0] : null;
|
||||
}
|
||||
|
||||
function buildText(heading, { cars, unassigned }, carMeet, defaults) {
|
||||
const lines = [`Fahrgemeinschaften${heading ? ': ' + heading : ''}`, ''];
|
||||
const common = commonMeeting(carMeet, defaults);
|
||||
if (common) {
|
||||
const ml = meetingLines(common);
|
||||
if (ml.length) lines.push(...ml, '');
|
||||
}
|
||||
cars.forEach((car, i) => {
|
||||
if (i > 0 && !common) lines.push('');
|
||||
lines.push(`Fahrzeug ${i + 1} – Fahrer: ${car.driver.name} (${car.passengers.length}/${car.driver.seats} belegt)`);
|
||||
if (!common) meetingLines(carMeet[i]).forEach((l) => lines.push(` ${l}`));
|
||||
car.passengers.forEach((p) => lines.push(` • ${p.name}`));
|
||||
if (!car.passengers.length) lines.push(' • (keine Mitfahrer)');
|
||||
});
|
||||
if (unassigned.length) {
|
||||
lines.push('', `OHNE PLATZ: ${unassigned.map((p) => p.name).join(', ')}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
$('#plan-btn').addEventListener('click', () => {
|
||||
const participants = currentParticipants();
|
||||
|
||||
const result = $('#result');
|
||||
if (!participants.length) {
|
||||
result.innerHTML = '<p class="warn">Bitte wähle mindestens eine Person aus.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = planTrip(participants, $('#opt-min-cars').checked, trip.fixed);
|
||||
const heading = [$('#trip-title').value.trim(), formatDate($('#trip-date').value)].filter(Boolean).join(' – ');
|
||||
const totalSeats = plan.cars.reduce((s, c) => s + 1 + c.driver.seats, 0);
|
||||
|
||||
// Treffpunkt und Zeiten pro Auto: Standard-Treffpunkt des Fahrers, sonst der Treffpunkt der Fahrt
|
||||
const defaults = {
|
||||
pointId: $('#trip-point').value,
|
||||
meet: $('#trip-meet').value,
|
||||
departure: $('#trip-departure').value,
|
||||
};
|
||||
const carMeet = plan.cars.map((car) => ({
|
||||
...defaults,
|
||||
pointId: points.some((p) => p.id === car.driver.meetingPoint) ? car.driver.meetingPoint : defaults.pointId,
|
||||
}));
|
||||
|
||||
const pointOptions = (selected) => points
|
||||
.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('')
|
||||
+ `<option value="" ${selected ? '' : 'selected'}>(kein Treffpunkt)</option>`;
|
||||
|
||||
let html = heading ? `<h3>${esc(heading)}</h3>` : '';
|
||||
html += '<div id="meeting-common"></div>';
|
||||
html += `<p class="muted">${participants.length} Personen · ${plan.cars.length} ${plan.cars.length === 1 ? 'Auto' : 'Autos'} · ${totalSeats} Sitzplätze</p>`;
|
||||
|
||||
html += '<div class="cars">' + plan.cars.map((car, i) => `
|
||||
<div class="car">
|
||||
<h3>Auto ${i + 1}: ${esc(car.driver.name)}${badges(car.driver)}</h3>
|
||||
<div class="muted">${car.passengers.length} / ${car.driver.seats} Plätze belegt</div>
|
||||
<div class="car-meet no-print">
|
||||
<label>Treffpunkt <select data-car-point="${i}">${pointOptions(carMeet[i].pointId)}</select></label>
|
||||
<div class="grid2">
|
||||
<label>Treffzeit <input type="time" data-car-meet="${i}" value="${esc(carMeet[i].meet)}"></label>
|
||||
<label>Abfahrt <input type="time" data-car-dep="${i}" value="${esc(carMeet[i].departure)}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div data-car-info="${i}"></div>
|
||||
${car.passengers.length
|
||||
? `<ol>${car.passengers.map((p) => `<li>${esc(p.name)}${badges(p)}${trip.fixed.get(p.id) === car.driver.id ? ' <span class="badge">fest</span>' : ''}</li>`).join('')}</ol>`
|
||||
: '<p class="muted">keine Mitfahrer</p>'}
|
||||
</div>`).join('') + '</div>';
|
||||
|
||||
if (plan.unassigned.length) {
|
||||
const missing = plan.unassigned.length;
|
||||
html += `<div class="warn"><strong>Nicht genug Plätze!</strong> Es fehlen ${seatsLabel(missing)}.<br>
|
||||
Ohne Platz: ${plan.unassigned.map((p) => esc(p.name)).join(', ')}</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="row wrap no-print">
|
||||
<button id="copy-btn">Als Text kopieren</button>
|
||||
<button id="print-btn">Drucken</button>
|
||||
<button id="reset-after-btn">Neue Fahrt</button>
|
||||
</div>`;
|
||||
|
||||
result.innerHTML = html;
|
||||
|
||||
function renderMeetings() {
|
||||
const common = commonMeeting(carMeet, defaults);
|
||||
$('#meeting-common').innerHTML = common ? meetingHtml(common) : '';
|
||||
carMeet.forEach((m, i) => {
|
||||
$(`[data-car-info="${i}"]`).innerHTML = common ? '' : meetingHtml(m);
|
||||
});
|
||||
}
|
||||
renderMeetings();
|
||||
|
||||
result.querySelector('.cars').addEventListener('change', (e) => {
|
||||
const { carPoint, carMeet: meetIdx, carDep } = e.target.dataset;
|
||||
if (carPoint !== undefined) carMeet[carPoint].pointId = e.target.value;
|
||||
if (meetIdx !== undefined) carMeet[meetIdx].meet = e.target.value;
|
||||
if (carDep !== undefined) {
|
||||
// Treffzeit folgt der Abfahrt wie oben bei der Fahrt
|
||||
const m = carMeet[carDep];
|
||||
m.departure = e.target.value;
|
||||
if (m.departure) {
|
||||
m.meet = minusMinutes(m.departure, MEET_BEFORE_MIN);
|
||||
$(`[data-car-meet="${carDep}"]`).value = m.meet;
|
||||
}
|
||||
}
|
||||
$('#copy-btn').textContent = 'Als Text kopieren';
|
||||
renderMeetings();
|
||||
});
|
||||
|
||||
$('#copy-btn').addEventListener('click', async (e) => {
|
||||
const text = buildText(heading, plan, carMeet, defaults);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
e.target.textContent = 'Kopiert';
|
||||
} catch {
|
||||
prompt('Text zum Kopieren:', text);
|
||||
}
|
||||
});
|
||||
$('#print-btn').addEventListener('click', () => window.print());
|
||||
$('#reset-after-btn').addEventListener('click', resetTrip);
|
||||
});
|
||||
|
||||
// ---------- Übernahme aus Kalender-Termin ----------
|
||||
async function applyEventFromUrl() {
|
||||
const eventId = new URLSearchParams(location.search).get('termin');
|
||||
if (!eventId) return;
|
||||
let ev;
|
||||
try {
|
||||
ev = await api('GET', `/events/${encodeURIComponent(eventId)}`);
|
||||
} catch {
|
||||
showError(new Error('Der Termin wurde nicht gefunden.'));
|
||||
return;
|
||||
}
|
||||
const known = ev.attendees.filter((id) => members.some((m) => m.id === id));
|
||||
known.forEach((id) => trip.selected.add(id));
|
||||
$('#trip-title').value = ev.title;
|
||||
$('#trip-date').value = ev.date;
|
||||
const notice = $('#from-event');
|
||||
notice.innerHTML = `Übernommen aus dem Kalender: <strong>${esc(ev.title)}</strong> am ${formatDate(ev.date, true)} –
|
||||
${known.length} ${known.length === 1 ? 'Zusage ist' : 'Zusagen sind'} schon als „kommt mit“ ausgewählt.`;
|
||||
notice.hidden = false;
|
||||
}
|
||||
|
||||
// ---------- Start ----------
|
||||
async function init() {
|
||||
[members, points] = await Promise.all([api('GET', '/members'), api('GET', '/meeting-points')]);
|
||||
renderPoints();
|
||||
await applyEventFromUrl();
|
||||
renderTripMembers();
|
||||
renderGuests();
|
||||
}
|
||||
|
||||
init().catch(showError);
|
||||
@@ -0,0 +1,216 @@
|
||||
import { $, api, esc, sortByName, seatsLabel, showError, renderHeader } from './common.js';
|
||||
|
||||
renderHeader('gruppe');
|
||||
|
||||
let members = [];
|
||||
let points = [];
|
||||
const form = $('#member-form');
|
||||
|
||||
$('#member-hascar').addEventListener('change', (e) => {
|
||||
$('#member-seats-wrap').hidden = !e.target.checked;
|
||||
});
|
||||
|
||||
// Auswahl "fährt am liebsten mit bei" – alle anderen Mitglieder
|
||||
function fillRidesWith(selfId, selected) {
|
||||
$('#member-rideswith').innerHTML = '<option value="">(keine Vorliebe)</option>'
|
||||
+ sortByName(members.filter((m) => m.id !== selfId))
|
||||
.map((m) => `<option value="${m.id}" ${m.id === selected ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
// Auswahl Standard-Treffpunkt
|
||||
function fillMemberPoint(selected) {
|
||||
$('#member-point').innerHTML = '<option value="">(Treffpunkt der Fahrt)</option>'
|
||||
+ points.map((p) => `<option value="${p.id}" ${p.id === selected ? 'selected' : ''}>${esc(p.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
fillRidesWith();
|
||||
fillMemberPoint();
|
||||
$('#member-id').value = '';
|
||||
$('#member-seats-wrap').hidden = true;
|
||||
$('#member-cancel').hidden = true;
|
||||
$('#member-form-title').textContent = 'Mitglied hinzufügen';
|
||||
}
|
||||
|
||||
$('#member-cancel').addEventListener('click', resetForm);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
name: $('#member-name').value.trim(),
|
||||
external: $('#member-external').checked,
|
||||
hasCar: $('#member-hascar').checked,
|
||||
seats: $('#member-seats').value,
|
||||
alwaysDrives: $('#member-always').checked,
|
||||
ridesWith: $('#member-rideswith').value || null,
|
||||
meetingPoint: $('#member-point').value || null,
|
||||
};
|
||||
const id = $('#member-id').value;
|
||||
try {
|
||||
if (id) await api('PUT', `/members/${id}`, body);
|
||||
else await api('POST', '/members', body);
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
function edit(id) {
|
||||
const m = members.find((x) => x.id === id);
|
||||
$('#member-id').value = m.id;
|
||||
$('#member-name').value = m.name;
|
||||
$('#member-external').checked = !!m.external;
|
||||
fillMemberPoint(m.meetingPoint);
|
||||
$('#member-hascar').checked = m.hasCar;
|
||||
$('#member-seats').value = m.seats || 4;
|
||||
$('#member-always').checked = !!m.alwaysDrives;
|
||||
fillRidesWith(m.id, m.ridesWith);
|
||||
$('#member-seats-wrap').hidden = !m.hasCar;
|
||||
$('#member-cancel').hidden = false;
|
||||
$('#member-form-title').textContent = 'Mitglied bearbeiten';
|
||||
$('#member-name').focus();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const m = members.find((x) => x.id === id);
|
||||
if (!confirm(`${m.name} wirklich aus der Gruppe löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/members/${id}`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!$('#member-id').value) fillRidesWith();
|
||||
const nameOf = (id) => members.find((m) => m.id === id)?.name;
|
||||
const pointOf = (id) => points.find((p) => p.id === id)?.name;
|
||||
$('#members-empty').hidden = members.length > 0;
|
||||
$('#members-list').innerHTML = sortByName(members).map((m) => `
|
||||
<li>
|
||||
<span class="name">${esc(m.name)}${m.external ? ' <span class="badge">Extern</span>' : ''}</span>
|
||||
${m.hasCar && pointOf(m.meetingPoint) ? `<span class="meta">ab ${esc(pointOf(m.meetingPoint))}</span>` : ''}
|
||||
${nameOf(m.ridesWith) ? `<span class="meta">fährt gern bei ${esc(nameOf(m.ridesWith))} mit</span>` : ''}
|
||||
<span class="badge">${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'}</span>
|
||||
<span class="actions">
|
||||
<button class="link" data-edit="${m.id}">Bearbeiten</button>
|
||||
<button class="link danger" data-del="${m.id}">Löschen</button>
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#members-list').addEventListener('click', (e) => {
|
||||
if (e.target.dataset.edit) edit(e.target.dataset.edit);
|
||||
if (e.target.dataset.del) remove(e.target.dataset.del);
|
||||
});
|
||||
|
||||
$('#export-btn').addEventListener('click', () => {
|
||||
const blob = new Blob([JSON.stringify(members, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'hcrb-gruppe.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
$('#import-file').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(await file.text());
|
||||
if (!Array.isArray(data)) throw new Error();
|
||||
} catch {
|
||||
alert('Die Datei konnte nicht gelesen werden.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`${data.length} Mitglieder importieren? Die aktuelle Gruppe wird ersetzt.`)) return;
|
||||
try {
|
||||
await api('POST', '/members/import', data);
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Treffpunkte ----------
|
||||
const pointForm = $('#point-form');
|
||||
|
||||
function resetPointForm() {
|
||||
pointForm.reset();
|
||||
$('#point-id').value = '';
|
||||
$('#point-cancel').hidden = true;
|
||||
$('#point-form-title').textContent = 'Treffpunkt hinzufügen';
|
||||
}
|
||||
|
||||
$('#point-cancel').addEventListener('click', resetPointForm);
|
||||
|
||||
pointForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = { name: $('#point-name').value.trim(), address: $('#point-address').value.trim() };
|
||||
const id = $('#point-id').value;
|
||||
try {
|
||||
if (id) await api('PUT', `/meeting-points/${id}`, body);
|
||||
else await api('POST', '/meeting-points', body);
|
||||
resetPointForm();
|
||||
await loadPoints();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
function renderPoints() {
|
||||
$('#points-empty').hidden = points.length > 0;
|
||||
$('#points-list').innerHTML = points.map((p) => `
|
||||
<li>
|
||||
<span class="name">${esc(p.name)}</span>
|
||||
<span class="meta">${esc(p.address)}</span>
|
||||
<span class="actions">
|
||||
<button class="link" data-edit="${p.id}">Bearbeiten</button>
|
||||
<button class="link danger" data-del="${p.id}">Löschen</button>
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#points-list').addEventListener('click', async (e) => {
|
||||
const { edit: editId, del } = e.target.dataset;
|
||||
if (editId) {
|
||||
const p = points.find((x) => x.id === editId);
|
||||
$('#point-id').value = p.id;
|
||||
$('#point-name').value = p.name;
|
||||
$('#point-address').value = p.address;
|
||||
$('#point-cancel').hidden = false;
|
||||
$('#point-form-title').textContent = 'Treffpunkt bearbeiten';
|
||||
$('#point-name').focus();
|
||||
}
|
||||
if (del) {
|
||||
const p = points.find((x) => x.id === del);
|
||||
if (!confirm(`Treffpunkt „${p.name}“ wirklich löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/meeting-points/${del}`);
|
||||
await loadPoints();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPoints() {
|
||||
points = await api('GET', '/meeting-points');
|
||||
renderPoints();
|
||||
const editing = members.find((m) => m.id === $('#member-id').value);
|
||||
fillMemberPoint(editing ? $('#member-point').value : undefined);
|
||||
render();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
members = await api('GET', '/members');
|
||||
render();
|
||||
}
|
||||
|
||||
load().then(loadPoints).catch(showError);
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
$, api, esc, sortByName, showError, renderHeader,
|
||||
toIso, fromIso, formatDate, getMe, setMe,
|
||||
} from './common.js';
|
||||
|
||||
renderHeader('kalender');
|
||||
|
||||
let members = [];
|
||||
let events = [];
|
||||
let me = getMe();
|
||||
|
||||
const todayIso = toIso(new Date());
|
||||
let selectedDay = todayIso;
|
||||
let viewMonth = fromIso(todayIso);
|
||||
viewMonth.setDate(1);
|
||||
|
||||
const memberName = (id) => members.find((m) => m.id === id)?.name ?? '(gelöscht)';
|
||||
|
||||
// ---------- "Ich bin" ----------
|
||||
function renderMe() {
|
||||
if (me && !members.some((m) => m.id === me)) me = null;
|
||||
$('#me-select').innerHTML = '<option value="">– bitte auswählen –</option>' +
|
||||
sortByName(members).map((m) =>
|
||||
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
|
||||
$('#me-hint').hidden = !!me;
|
||||
$('#event-form button[type=submit]').disabled = !me;
|
||||
}
|
||||
|
||||
$('#me-select').addEventListener('change', (e) => {
|
||||
me = e.target.value || null;
|
||||
setMe(me);
|
||||
renderAll();
|
||||
});
|
||||
|
||||
// ---------- Monatsansicht ----------
|
||||
function renderCalendar() {
|
||||
const title = viewMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
$('#month-title').textContent = title;
|
||||
|
||||
// Raster beginnt am Montag vor (oder am) Monatsersten
|
||||
const start = new Date(viewMonth);
|
||||
start.setDate(1 - ((start.getDay() + 6) % 7));
|
||||
|
||||
const byDate = new Map();
|
||||
for (const ev of events) {
|
||||
if (!byDate.has(ev.date)) byDate.set(ev.date, []);
|
||||
byDate.get(ev.date).push(ev);
|
||||
}
|
||||
|
||||
let html = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((d) => `<div class="dow">${d}</div>`).join('');
|
||||
const day = new Date(start);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const iso = toIso(day);
|
||||
const classes = ['cal-day'];
|
||||
if (day.getMonth() !== viewMonth.getMonth()) classes.push('other');
|
||||
if (iso === todayIso) classes.push('today');
|
||||
if (iso === selectedDay) classes.push('selected');
|
||||
const dayEvents = byDate.get(iso) || [];
|
||||
html += `
|
||||
<button class="${classes.join(' ')}" data-day="${iso}">
|
||||
<span class="num">${day.getDate()}</span>
|
||||
${dayEvents.map((ev) => `<span class="ev ${ev.attendees.includes(me) ? 'mine' : ''}"
|
||||
title="${esc(ev.title)} (${ev.attendees.length} zugesagt)">${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
|
||||
</button>`;
|
||||
day.setDate(day.getDate() + 1);
|
||||
}
|
||||
$('#calendar').innerHTML = html;
|
||||
}
|
||||
|
||||
$('#calendar').addEventListener('click', (e) => {
|
||||
const cell = e.target.closest('[data-day]');
|
||||
if (!cell) return;
|
||||
selectedDay = cell.dataset.day;
|
||||
const d = fromIso(selectedDay);
|
||||
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
renderAll();
|
||||
$('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
|
||||
function shiftMonth(delta) {
|
||||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
|
||||
renderCalendar();
|
||||
}
|
||||
$('#prev-month').addEventListener('click', () => shiftMonth(-1));
|
||||
$('#next-month').addEventListener('click', () => shiftMonth(1));
|
||||
$('#today-btn').addEventListener('click', () => {
|
||||
selectedDay = todayIso;
|
||||
viewMonth = fromIso(todayIso);
|
||||
viewMonth.setDate(1);
|
||||
renderAll();
|
||||
});
|
||||
|
||||
// ---------- Termine ----------
|
||||
function eventHtml(ev) {
|
||||
const going = ev.attendees.includes(me);
|
||||
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
|
||||
return `
|
||||
<div class="event">
|
||||
<h3>${esc(ev.title)}</h3>
|
||||
<div class="when">${formatDate(ev.date, true)}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div>
|
||||
${ev.note ? `<div class="note">${esc(ev.note)}</div>` : ''}
|
||||
<div class="muted">${ev.attendees.length} zugesagt:</div>
|
||||
<div class="chips">
|
||||
${attendees.length
|
||||
? attendees.map((a) => `<span class="chip ${a.id === me ? 'me' : ''}">${esc(a.name)}</span>`).join('')
|
||||
: '<span class="muted">noch niemand</span>'}
|
||||
</div>
|
||||
<div class="row wrap">
|
||||
${me ? (going
|
||||
? `<button data-action="leave" data-id="${ev.id}">Absagen</button>`
|
||||
: `<button class="primary" data-action="join" data-id="${ev.id}">Ich bin dabei</button>`) : ''}
|
||||
<a class="button" href="/fahrt?termin=${ev.id}">Fahrt planen</a>
|
||||
<button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDay() {
|
||||
$('#day-title').textContent = fromIso(selectedDay).toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
});
|
||||
const dayEvents = events.filter((e) => e.date === selectedDay);
|
||||
$('#day-events').innerHTML = dayEvents.length
|
||||
? dayEvents.map(eventHtml).join('')
|
||||
: '<p class="muted">An diesem Tag ist noch kein Spieltag eingetragen.</p>';
|
||||
}
|
||||
|
||||
function renderUpcoming() {
|
||||
const upcoming = events.filter((e) => e.date >= todayIso);
|
||||
$('#upcoming').innerHTML = upcoming.length
|
||||
? upcoming.map(eventHtml).join('')
|
||||
: '<p class="muted">Keine kommenden Spieltage.</p>';
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const { action, id } = btn.dataset;
|
||||
try {
|
||||
if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`);
|
||||
if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`);
|
||||
if (action === 'delete') {
|
||||
const ev = events.find((x) => x.id === id);
|
||||
if (!confirm(`Spieltag „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
|
||||
await api('DELETE', `/events/${id}`);
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
$('#event-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('POST', '/events', {
|
||||
date: selectedDay,
|
||||
title: $('#event-title').value,
|
||||
note: $('#event-note').value,
|
||||
createdBy: me,
|
||||
});
|
||||
e.target.reset();
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Laden ----------
|
||||
function renderAll() {
|
||||
renderMe();
|
||||
renderCalendar();
|
||||
renderDay();
|
||||
renderUpcoming();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[members, events] = await Promise.all([api('GET', '/members'), api('GET', '/events')]);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
load().catch(showError);
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kalender – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<label>Ich bin
|
||||
<select id="me-select"></select>
|
||||
</label>
|
||||
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Spieltage eintragen und zusagen kannst.
|
||||
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="cal-nav">
|
||||
<button id="prev-month" aria-label="Vorheriger Monat">‹</button>
|
||||
<h2 id="month-title"></h2>
|
||||
<button id="next-month" aria-label="Nächster Monat">›</button>
|
||||
</div>
|
||||
<div class="row end"><button id="today-btn" class="small">Heute</button></div>
|
||||
<div id="calendar" class="cal-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 id="day-title"></h2>
|
||||
<div id="day-events"></div>
|
||||
<form id="event-form">
|
||||
<h3>Spieltag an diesem Tag eintragen</h3>
|
||||
<label>Titel / Spielfeld
|
||||
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Waldfeld">
|
||||
</label>
|
||||
<label>Notiz (optional)
|
||||
<textarea id="event-note" maxlength="500" placeholder="Uhrzeit, Link zum Event, Treffpunkt …"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="primary">Eintragen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Kommende Spieltage</h2>
|
||||
<div id="upcoming"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/kalender.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,440 @@
|
||||
/* Farben aus der Gruppenflagge: Marineblau #354173, Gold #e4b84a, Stahlgrau #cacaca */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--flag: #354173;
|
||||
--bg: #1b2140;
|
||||
--bg-deep: #141932;
|
||||
--card: #232a4d;
|
||||
--card-2: #2b335a;
|
||||
--text: #e9eaf0;
|
||||
--muted: #a1a7c2;
|
||||
--border: #3d4775;
|
||||
--accent: #e4b84a;
|
||||
--accent-text: #141932;
|
||||
--accent-soft: rgba(228, 184, 74, .14);
|
||||
--steel: #cacaca;
|
||||
--warn: #ec7f6a;
|
||||
--chip: #333c66;
|
||||
--radius: 2px;
|
||||
--font-head: "Bahnschrift", "DIN Alternate", "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
/* Fußzeile bleibt auch auf kurzen Seiten unten */
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
h1, h2, h3, .brand, .site-header nav a, button, .button, .badge, .cal-grid .dow {
|
||||
font-family: var(--font-head);
|
||||
}
|
||||
h1, h2, h3 { text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ---------- Kopfzeile ---------- */
|
||||
.site-header {
|
||||
background: var(--bg-deep);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
padding: 10px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.site-header .inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: .18em;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand img { width: 40px; height: 40px; margin-right: 10px; flex: none; }
|
||||
.brand small {
|
||||
font-size: .72rem;
|
||||
font-weight: 400;
|
||||
color: var(--steel);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.site-header nav { display: flex; gap: 2px; flex-wrap: wrap; margin-left: auto; }
|
||||
.site-header nav a, .site-header nav button {
|
||||
text-decoration: none;
|
||||
color: var(--steel);
|
||||
padding: 6px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
font-size: .85rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.site-header nav button { background: none; border-width: 0 0 2px; border-radius: 0; }
|
||||
.site-header nav a:hover, .site-header nav button:hover { color: var(--text); }
|
||||
.site-header nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
main { max-width: 960px; width: 100%; margin: 0 auto; padding: 16px; flex: 1 0 auto; }
|
||||
main.narrow { max-width: 760px; }
|
||||
main.login { max-width: 420px; padding-top: 48px; }
|
||||
|
||||
/* ---------- Bausteine ---------- */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: .95rem;
|
||||
margin: 0 0 14px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.card h3 { font-size: .9rem; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.card-head h2 { margin: 0; }
|
||||
.card-head select { width: auto; margin-top: 0; }
|
||||
|
||||
label { display: block; margin-bottom: 10px; font-size: .92rem; color: var(--muted); }
|
||||
label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
|
||||
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
.row input[type=text], .row input[type=number], .row select { width: auto; flex: 1 1 120px; margin-top: 0; }
|
||||
.row input[type=number] { flex: 0 0 80px; }
|
||||
|
||||
button, .button {
|
||||
font-size: .85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
button:hover, .button:hover { border-color: var(--steel); }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary, .button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 700; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.danger { color: var(--warn); }
|
||||
button.link { background: none; border: none; padding: 4px 6px; color: var(--muted); }
|
||||
button.link:hover { color: var(--text); }
|
||||
button.small, .button.small { padding: 4px 10px; font-size: .78rem; }
|
||||
|
||||
.row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.row.end { justify-content: flex-end; }
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 560px) { .grid2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.muted { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
.list { list-style: none; padding: 0; margin: 0; }
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.list li:last-child { border-bottom: none; }
|
||||
.list .name { flex: 1; }
|
||||
/* Aktionen rücken nach rechts und brechen bei Platzmangel gemeinsam in die nächste Zeile um */
|
||||
.list .actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.list li > .link:last-child { margin-left: auto; }
|
||||
.list .meta { color: var(--muted); font-size: .85rem; }
|
||||
.list label { margin: 0; }
|
||||
|
||||
.badge {
|
||||
font-size: .72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--steel);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { font-size: .85rem; padding: 3px 10px; border-radius: var(--radius); background: var(--chip); }
|
||||
.chip.me { background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
.notice {
|
||||
border: 1px solid var(--accent);
|
||||
border-left-width: 4px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
border: 1px solid var(--warn);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Startseite ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
height: max(380px, 72vh);
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 55%, var(--bg) 100%),
|
||||
var(--flag) url("/img/flagge.jpg") center 35% / contain no-repeat;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||
margin: 0;
|
||||
letter-spacing: .3em;
|
||||
margin-right: -.3em; /* gleicht letter-spacing am Ende aus */
|
||||
color: var(--accent);
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.hero .subtitle {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .35em;
|
||||
color: var(--steel);
|
||||
font-size: clamp(.8rem, 2.4vw, 1.05rem);
|
||||
border-top: 1px solid var(--accent);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* Hochformat: Flagge füllt die Fläche, damit das Zahnrad groß bleibt */
|
||||
.hero { height: min(78vh, 480px); background-size: auto, auto 70%; background-position: center, center 20%; }
|
||||
}
|
||||
|
||||
.intro { max-width: 680px; margin: 8px auto 0; text-align: center; color: var(--muted); }
|
||||
.notice.wip { max-width: 680px; margin: 16px auto 0; text-align: center; }
|
||||
|
||||
.social { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.social .button { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.social svg { width: 18px; height: 18px; flex: none; }
|
||||
.footer-links { margin-top: 8px; display: flex; gap: 16px; justify-content: center; }
|
||||
.footer-links a { color: var(--muted); }
|
||||
.footer-links a:hover { color: var(--accent); }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
|
||||
@media (max-width: 560px) { .tiles { grid-template-columns: 1fr; } }
|
||||
.tile {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.tile:hover { background: var(--card-2); border-color: var(--accent); }
|
||||
.tile svg { flex: 0 0 36px; width: 36px; height: 36px; color: var(--accent); }
|
||||
.tile h2 { margin: 0 0 4px; font-size: 1.1rem; }
|
||||
.tile p { margin: 0; color: var(--muted); }
|
||||
|
||||
/* ---------- Kalender ---------- */
|
||||
.cal-nav { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
.cal-nav h2 { margin: 0; font-size: 1rem; text-align: center; flex: 1; border: none; padding: 0; }
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
.cal-grid .dow {
|
||||
text-align: center;
|
||||
font-size: .75rem;
|
||||
color: var(--muted);
|
||||
padding: 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.cal-day {
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
background: var(--bg-deep);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-family: inherit;
|
||||
font-size: .85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-day.other { opacity: .35; }
|
||||
.cal-day.today .num { background: var(--steel); color: var(--bg-deep); padding: 0 6px; }
|
||||
.cal-day.selected { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
.cal-day .num { align-self: flex-start; font-weight: 600; }
|
||||
.cal-day .ev {
|
||||
font-size: .72rem;
|
||||
background: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cal-day .ev.mine { background: var(--accent); color: var(--accent-text); }
|
||||
@media (max-width: 560px) {
|
||||
.cal-day { min-height: 52px; padding: 2px; }
|
||||
.cal-day .ev { font-size: 0; padding: 0; height: 6px; }
|
||||
}
|
||||
|
||||
.event {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
.event h3 { margin: 0; font-size: .95rem; }
|
||||
.event .when { color: var(--muted); font-size: .85rem; margin-bottom: 6px; }
|
||||
.event .note { margin: 6px 0; white-space: pre-wrap; }
|
||||
|
||||
/* ---------- Statistik ---------- */
|
||||
.stats .rank { color: var(--muted); min-width: 3ch; font-family: var(--font-head); }
|
||||
.stat-total {
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-family: var(--font-head);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Fahrtplaner ---------- */
|
||||
.cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||
.car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.car-meet label { font-size: .8rem; margin-bottom: 6px; }
|
||||
.car-meet .grid2 { gap: 8px; grid-template-columns: 1fr 1fr; }
|
||||
.car {
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--steel);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
.car h3 { margin: 0 0 4px; font-size: .9rem; }
|
||||
.car ol { margin: 6px 0 0; padding-left: 20px; }
|
||||
.meeting { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 8px 0 4px; }
|
||||
.meeting dt { color: var(--muted); }
|
||||
.meeting dd { margin: 0; font-weight: 600; }
|
||||
.meeting dd small { display: block; font-weight: 400; color: var(--muted); }
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .2em;
|
||||
font-size: .75rem;
|
||||
padding: 24px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Responsiv ---------- */
|
||||
.nav-toggle { display: none; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header { padding: 8px 16px; }
|
||||
.site-header .inner { gap: 8px; }
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: var(--steel);
|
||||
}
|
||||
.site-header.open .nav-toggle { color: var(--accent); border-color: var(--accent); }
|
||||
.site-header nav { display: none; width: 100%; flex-direction: column; margin: 4px 0 0; gap: 0; }
|
||||
.site-header.open nav { display: flex; }
|
||||
.site-header nav a, .site-header nav button {
|
||||
padding: 12px 4px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.site-header nav a:last-child, .site-header nav button:last-child { border-bottom: none; }
|
||||
.site-header nav a.active { border-bottom-color: var(--border); border-left: 3px solid var(--accent); padding-left: 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand small { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
main { padding: 12px; }
|
||||
.card { padding: 14px; }
|
||||
/* iOS zoomt bei Schrift unter 16px ins Feld */
|
||||
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea { font-size: 16px; }
|
||||
.stats .meta { flex-basis: 100%; order: 10; padding-left: calc(3ch + 10px); margin-top: -6px; }
|
||||
}
|
||||
|
||||
/* Touch-Geräte: ausreichend große Tippflächen */
|
||||
@media (pointer: coarse) {
|
||||
button.small, .button.small { min-height: 36px; min-width: 36px; }
|
||||
button.link { padding: 8px 8px; }
|
||||
input[type=checkbox] { width: 20px; height: 20px; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.site-header, footer, .no-print { display: none !important; }
|
||||
:root { --text: #000; --muted: #444; --border: #999; --bg-deep: #fff; --card: #fff; }
|
||||
body { background: #fff; color: #000; }
|
||||
.card { border: none; }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HCRB – Highcap Rasselbande</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>HCRB</h1>
|
||||
<div class="subtitle">Highcap Rasselbande</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<p class="intro">
|
||||
Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin.
|
||||
Am häufigsten findet ihr uns auf der Darkzone.
|
||||
</p>
|
||||
|
||||
<p class="notice wip">
|
||||
<strong>Diese Seite ist noch in Arbeit.</strong> Es kommen noch Änderungen und neue Funktionen dazu.
|
||||
</p>
|
||||
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/kalender">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="16"/><path d="M3 10h18M8 3v4M16 3v4M7 14h3M14 14h3M7 17h3"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>Kalender</h2>
|
||||
<p>Spieltage eintragen und zusagen.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tile" href="/fahrt">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
|
||||
<path d="M3 16v-4l2-5h14l2 5v4H3z"/><path d="M3 12h18"/><circle cx="7.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>Fahrtplaner</h2>
|
||||
<p>Fahrgemeinschaften einteilen.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Nächste Spieltage</h2>
|
||||
<a href="/kalender" class="button small">Zum Kalender</a>
|
||||
</div>
|
||||
<div id="upcoming"><p class="muted">Lädt …</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Pile of Shame</h2>
|
||||
<a href="/statistik" class="button small">Zur Statistik</a>
|
||||
</div>
|
||||
<div id="shame"><p class="muted">Lädt …</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Über uns</h2>
|
||||
<p>
|
||||
Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin.
|
||||
Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong> – dort sind wir regelmäßig unterwegs.
|
||||
</p>
|
||||
<p>
|
||||
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
|
||||
gibt es auf YouTube und Instagram:
|
||||
</p>
|
||||
<div class="social">
|
||||
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/></svg>YouTube
|
||||
</a>
|
||||
<a class="button" href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.6" cy="6.4" r="1" fill="currentColor" stroke="none"/></svg>Instagram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="module">
|
||||
import { renderHeader, renderFooter, api, esc, formatDate } from '/js/common.js';
|
||||
|
||||
renderHeader('home');
|
||||
renderFooter();
|
||||
|
||||
const box = document.getElementById('upcoming');
|
||||
try {
|
||||
const events = await api('GET', '/upcoming');
|
||||
box.innerHTML = events.length
|
||||
? `<ul class="list">${events.map((e) => `
|
||||
<li>
|
||||
<span class="name"><strong>${esc(e.title)}</strong></span>
|
||||
<span class="meta">${formatDate(e.date, true)}</span>
|
||||
<span class="badge">${e.attendeeCount} zugesagt</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="muted">Noch keine Spieltage eingetragen.</p>';
|
||||
} catch {
|
||||
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
|
||||
}
|
||||
|
||||
// Pile of Shame: wer dieses Jahr noch nicht spielen war
|
||||
const shame = document.getElementById('shame');
|
||||
try {
|
||||
const { year, entries } = await api('GET', '/stats');
|
||||
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
|
||||
shame.innerHTML = names.length
|
||||
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
|
||||
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
|
||||
: `<p class="muted">Leer – alle waren ${year} schon spielen.</p>`;
|
||||
} catch {
|
||||
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<div class="inner">
|
||||
<a class="brand" href="/"><img src="/img/logo.png" alt="" width="40" height="40">HCRB<small>Highcap Rasselbande</small></a>
|
||||
<button type="button" class="nav-toggle" aria-expanded="false" aria-controls="site-nav" aria-label="Menü">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<nav id="site-nav">
|
||||
${NAV.map((n) => `<a href="${n.href}" class="${n.key === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||
</nav>
|
||||
</div>`;
|
||||
document.body.prepend(header);
|
||||
// Auf schmalen Bildschirmen klappt das Menü über die Schaltfläche auf
|
||||
const toggle = header.querySelector('.nav-toggle');
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = header.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', open);
|
||||
});
|
||||
if (!document.querySelector('link[rel="icon"]')) {
|
||||
document.head.insertAdjacentHTML('beforeend', '<link rel="icon" type="image/png" href="/img/favicon.png">');
|
||||
}
|
||||
if (active !== 'login') renderAuthLink(header.querySelector('nav'));
|
||||
}
|
||||
|
||||
// ---------- Fußzeile (öffentliche Seiten) ----------
|
||||
export function renderFooter() {
|
||||
const footer = document.createElement('footer');
|
||||
footer.innerHTML = `
|
||||
HCRB · Highcap Rasselbande · Berlin
|
||||
<div class="footer-links">
|
||||
<a href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">YouTube</a>
|
||||
<a href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">Instagram</a>
|
||||
</div>`;
|
||||
document.body.append(footer);
|
||||
}
|
||||
|
||||
async function renderAuthLink(nav) {
|
||||
const { user } = await api('GET', '/me').catch(() => ({}));
|
||||
if (!user) {
|
||||
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Abmelden';
|
||||
btn.title = `Angemeldet als ${user}`;
|
||||
btn.addEventListener('click', async () => {
|
||||
await api('POST', '/logout').catch(() => {});
|
||||
location.href = '/';
|
||||
});
|
||||
nav.append(btn);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
|
||||
|
||||
renderHeader('statistik');
|
||||
renderFooter();
|
||||
|
||||
let stats = [];
|
||||
let canEdit = false; // angemeldet und laufendes Jahr
|
||||
|
||||
function detail(s) {
|
||||
if (!s.manual) return '';
|
||||
const sign = s.manual > 0 ? '+' : '−';
|
||||
return `<span class="meta">${s.calendar} aus Kalender ${sign} ${Math.abs(s.manual)} manuell</span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
$('#stats-empty').hidden = stats.length > 0;
|
||||
$('#stats-edit-hint').hidden = !canEdit;
|
||||
// Gleiche Anzahl → gleicher Platz
|
||||
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
|
||||
$('#stats-list').innerHTML = stats.map((s) => `
|
||||
<li>
|
||||
<span class="rank">${rank(s)}.</span>
|
||||
<span class="name">${esc(s.name)}</span>
|
||||
${detail(s)}
|
||||
<span class="actions">
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
|
||||
<span class="stat-total">${s.total}</span>
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="1" data-id="${s.id}" aria-label="Ein Spiel mehr">+</button>` : ''}
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#stats-list').addEventListener('click', async (e) => {
|
||||
const { step, id } = e.target.dataset;
|
||||
if (!step) return;
|
||||
const s = stats.find((x) => x.id === id);
|
||||
try {
|
||||
const { manual } = await api('PUT', `/members/${id}/plays-manual`, { manual: Math.max(s.manual, -s.calendar) + Number(step) });
|
||||
s.manual = manual;
|
||||
s.total = Math.max(0, s.calendar + manual);
|
||||
stats.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
|
||||
render();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
async function load(year) {
|
||||
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
|
||||
stats = data.entries;
|
||||
canEdit = loggedIn && data.live;
|
||||
$('#stats-year').innerHTML = data.years
|
||||
.map((y, i) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).join('');
|
||||
$('#stats-info').textContent = data.live
|
||||
? `Gezählt werden die Spieltage seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat. `
|
||||
+ 'Zu Neujahr wird das Jahr gespeichert und die Zählung beginnt von vorn. Externe werden nicht aufgeführt.'
|
||||
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
|
||||
render();
|
||||
}
|
||||
|
||||
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
|
||||
|
||||
api('GET', '/me')
|
||||
.then(({ user }) => { loggedIn = !!user; return load(); })
|
||||
.catch(showError);
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login">
|
||||
<div class="card">
|
||||
<h2>Anmelden</h2>
|
||||
<form id="login-form">
|
||||
<label>Benutzername
|
||||
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input type="password" id="login-password" required autocomplete="current-password">
|
||||
</label>
|
||||
<p id="login-error" class="warn" hidden></p>
|
||||
<button type="submit" class="primary">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Statistik – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Gespielte Spieltage</h2>
|
||||
<select id="stats-year" aria-label="Jahr"></select>
|
||||
</div>
|
||||
<p id="stats-info" class="muted"></p>
|
||||
<p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten,
|
||||
die nicht im Kalender standen.</p>
|
||||
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
|
||||
<ol id="stats-list" class="list stats"></ol>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/statistik.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
// Benutzerverwaltung für den Login
|
||||
//
|
||||
// npm run user -- list
|
||||
// npm run user -- add <name> Benutzer anlegen (Passwort wird abgefragt)
|
||||
// npm run user -- passwd <name> Passwort ändern (meldet alle Sitzungen des Benutzers ab)
|
||||
// npm run user -- remove <name> Benutzer löschen
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { hashPassword, readUsers, writeUsers, normalizeName, MIN_PASSWORD, USERS_FILE } from '../auth.js';
|
||||
|
||||
// Passwort verdeckt einlesen (bei umgeleiteter Eingabe zeilenweise)
|
||||
let pipedLines;
|
||||
function askPassword(prompt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { stdin, stdout } = process;
|
||||
stdout.write(prompt);
|
||||
if (!stdin.isTTY) {
|
||||
pipedLines ??= createInterface({ input: stdin })[Symbol.asyncIterator]();
|
||||
pipedLines.next().then(({ value }) => { stdout.write('\n'); resolve(value ?? ''); }, reject);
|
||||
return;
|
||||
}
|
||||
let input = '';
|
||||
stdin.setRawMode(true);
|
||||
stdin.setEncoding('utf8');
|
||||
stdin.resume();
|
||||
const onData = (chunk) => {
|
||||
for (const ch of chunk) {
|
||||
if (ch === '\r' || ch === '\n') {
|
||||
stdin.setRawMode(false);
|
||||
stdin.pause();
|
||||
stdin.off('data', onData);
|
||||
stdout.write('\n');
|
||||
return resolve(input);
|
||||
}
|
||||
if (ch === '\u0003') { // Strg+C
|
||||
stdin.setRawMode(false);
|
||||
stdout.write('\n');
|
||||
return reject(new Error('Abgebrochen.'));
|
||||
}
|
||||
if (ch === '\u007f' || ch === '\b') input = input.slice(0, -1);
|
||||
else input += ch;
|
||||
}
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function newPassword() {
|
||||
const pw = await askPassword('Neues Passwort: ');
|
||||
if (pw.length < MIN_PASSWORD) throw new Error(`Das Passwort muss mindestens ${MIN_PASSWORD} Zeichen haben.`);
|
||||
if (await askPassword('Passwort wiederholen: ') !== pw) throw new Error('Die Passwörter stimmen nicht überein.');
|
||||
return hashPassword(pw);
|
||||
}
|
||||
|
||||
const [cmd, rawName] = process.argv.slice(2);
|
||||
const name = normalizeName(rawName);
|
||||
const users = readUsers();
|
||||
const user = users.find((u) => u.name === name);
|
||||
|
||||
try {
|
||||
if (cmd === 'list') {
|
||||
console.log(users.length ? users.map((u) => u.name).join('\n') : 'Noch keine Benutzer angelegt.');
|
||||
} else if (cmd === 'add' && name) {
|
||||
if (!/^[a-z0-9._-]{2,32}$/.test(name)) {
|
||||
throw new Error('Benutzername: 2–32 Zeichen, nur a–z, 0–9, Punkt, Minus, Unterstrich.');
|
||||
}
|
||||
if (user) throw new Error(`Benutzer „${name}“ gibt es schon.`);
|
||||
users.push({ name, hash: await newPassword(), changedAt: Date.now() });
|
||||
writeUsers(users);
|
||||
console.log(`Benutzer „${name}“ angelegt.`);
|
||||
} else if (cmd === 'passwd' && name) {
|
||||
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
|
||||
user.hash = await newPassword();
|
||||
user.changedAt = Date.now();
|
||||
writeUsers(users);
|
||||
console.log(`Passwort von „${name}“ geändert. Bestehende Anmeldungen sind abgemeldet.`);
|
||||
} else if (cmd === 'remove' && name) {
|
||||
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
|
||||
writeUsers(users.filter((u) => u !== user));
|
||||
console.log(`Benutzer „${name}“ gelöscht.`);
|
||||
} else {
|
||||
console.log('Benutzung: npm run user -- list | add <name> | passwd <name> | remove <name>');
|
||||
console.log(`Benutzerdatei: ${USERS_FILE}`);
|
||||
process.exitCode = cmd ? 1 : 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
Reference in new issue
Block a user