mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen
- Gemeinsame Zugänge mit Rollen: Admin darf alles, Mitglied nutzt Kalender, Fahrtplaner, Treffpunkte und bearbeitet Mitglieder (kein Hinzufügen, Löschen, Import, keine Patches, keine Statistik-Korrektur) - Server prüft die Rechte (403), Konten ohne Rolle gelten als Admin - npm run user: add <name> [admin|mitglied], role <name> <rolle> - Neue öffentliche Seite Kooperationen mit Titelbild, Anführer, Social-Media-Links und Beschreibung, Pflege durch Admin - Kopfzeile bleibt bei mittlerer Breite einzeilig - Texte auf Wunsch gekürzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f46016487d
commit
819219fb2d
18 files changed
+661
-106
No files matched your search
@@ -87,10 +87,11 @@ docker compose ps # Status sollte nach ca. 30 Sekunden "Up … (healt
|
||||
Ohne übernommene Daten jetzt den ersten Login anlegen (Passwort wird verdeckt abgefragt):
|
||||
|
||||
```bash
|
||||
docker compose exec hcrb npm run user -- add elias
|
||||
docker compose exec hcrb npm run user -- add admin admin # Admin-Zugang
|
||||
docker compose exec hcrb npm run user -- add hcrb # gemeinsamer Zugang für alle Mitglieder
|
||||
```
|
||||
|
||||
Prüfen, ob die Seite läuft (sollte `{"user":null}` ausgeben – Server-IP und ggf. Port anpassen):
|
||||
Prüfen, ob die Seite läuft (sollte `{"user":null,"role":null}` ausgeben – Server-IP und ggf. Port anpassen):
|
||||
|
||||
```bash
|
||||
curl -s http://192.168.178.20:3000/api/me
|
||||
@@ -173,7 +174,8 @@ Alle im Ordner `~/festplatte/dockercontainer/hcrb` ausführen:
|
||||
| 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>` |
|
||||
| Benutzer anlegen | `docker compose exec hcrb npm run user -- add <name> [admin\|mitglied]` |
|
||||
| Rolle ändern | `docker compose exec hcrb npm run user -- role <name> <admin\|mitglied>` |
|
||||
| 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 ~/festplatte/dockercontainer/hcrb && sudo chown -R 1000:1000 data && docker compose up -d` |
|
||||
|
||||
@@ -38,26 +38,47 @@ Anderen Port wählen: `PORT=8080 npm start`.
|
||||
Die Startseite zeigt als „Pile of Shame“, wer im laufenden Jahr noch nicht spielen war.
|
||||
Der Jahreswechsel richtet sich nach der Zeitzone des Servers (im systemd-Dienst `Environment=TZ=Europe/Berlin` setzen).
|
||||
- **Patches** (`/patches`, öffentlich) – Galerie der Gruppen- und Event-Patches, getrennt nach „Gibt es schon“ und „In Planung“.
|
||||
Angemeldet: Patch hochladen (PNG, JPEG oder WebP – wird im Browser auf max. 1600 px verkleinert – oder SVG bis 2 MB),
|
||||
Admin-Zugang: Patch hochladen (PNG, JPEG oder WebP – wird im Browser auf max. 1600 px verkleinert – oder SVG bis 2 MB),
|
||||
bearbeiten, löschen. SVGs mit Skripten, Ereignis-Attributen oder Verweisen auf fremde Dateien werden abgelehnt
|
||||
und zusätzlich mit einer Content-Security-Policy ausgeliefert.
|
||||
Die Bilder liegen in `data/patches/` und sind damit im Docker-Volume und im Backup enthalten.
|
||||
- **Kooperationen** (`/kooperationen`, öffentlich) – Gruppen, mit denen die HCRB zusammenarbeitet. Übersicht als Karten,
|
||||
jede Kooperation hat eine eigene Ansicht (`/kooperationen#<id>`) mit Titelbild, Anführer, Social-Media-Links
|
||||
(Website, Instagram, YouTube, TikTok, Facebook, Discord) und Beschreibung. Anlegen, bearbeiten, löschen nur mit dem
|
||||
Admin-Zugang. Titelbilder liegen in `data/kooperationen/`.
|
||||
- **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.
|
||||
Hinzufügen, Löschen und Import nur mit dem Admin-Zugang.
|
||||
|
||||
## Login
|
||||
|
||||
Öffentlich sind nur die Startseite (inkl. der nächsten 3 Termine: Datum, Uhrzeit, Titel, Anzahl Zusagen), die Statistik
|
||||
(Namen und Anzahl gespielter Spieltage) und die Patches.
|
||||
(Namen und Anzahl gespielter Spieltage), die Patches und die Kooperationen.
|
||||
Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar.
|
||||
|
||||
Die Zugänge sind gemeinsame Konten, keine persönlichen. Es gibt zwei Rollen:
|
||||
|
||||
| | Admin | Mitglied |
|
||||
| --- | --- | --- |
|
||||
| Kalender: Termine eintragen, bearbeiten, löschen, zusagen | ✓ | ✓ |
|
||||
| Fahrtplaner, Treffpunkte | ✓ | ✓ |
|
||||
| Mitglieder bearbeiten, Gruppe exportieren | ✓ | ✓ |
|
||||
| Mitglieder hinzufügen, löschen, Gruppe importieren | ✓ | – |
|
||||
| Patches hochladen, bearbeiten, löschen | ✓ | – |
|
||||
| Kooperationen anlegen, bearbeiten, löschen | ✓ | – |
|
||||
| Statistik von Hand korrigieren (− / +) | ✓ | – |
|
||||
|
||||
Typisch: ein Konto `admin` und ein Konto für alle Mitglieder, z. B. `hcrb`.
|
||||
Konten aus der Zeit vor den Rollen gelten als Admin, bis man ihre Rolle ändert.
|
||||
|
||||
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)
|
||||
npm run user -- list # alle Benutzer mit Rolle anzeigen
|
||||
npm run user -- add <name> [admin|mitglied] # Benutzer anlegen (Standard: mitglied)
|
||||
npm run user -- role <name> <admin|mitglied> # Rolle ändern (meldet den Benutzer überall ab)
|
||||
npm run user -- passwd <name> # Passwort ändern (meldet den Benutzer überall ab)
|
||||
npm run user -- remove <name> # Benutzer löschen (sofort abgemeldet)
|
||||
```
|
||||
|
||||
Sicherheit:
|
||||
@@ -71,11 +92,11 @@ Sicherheit:
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points, /api/patches, .ics-Export)
|
||||
server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points, /api/patches, /api/kooperationen, .ics-Export)
|
||||
auth.js Login, Passwort-Hashing, Sitzungen
|
||||
scripts/user.js Benutzerverwaltung (npm run user)
|
||||
data/ db.json, users.json, sessions.json, patches/ (automatisch angelegt, nicht in Git)
|
||||
public/ öffentlich: Startseite, Statistik, Patches, Login, CSS, Bilder, Schriften, common.js
|
||||
data/ db.json, users.json, sessions.json, patches/, kooperationen/ (automatisch angelegt, nicht in Git)
|
||||
public/ öffentlich: Startseite, Statistik, Patches, Kooperationen, Login, CSS, Bilder, Schriften, common.js
|
||||
private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Rollen: Die Konten sind gemeinsame Zugänge, keine persönlichen.
|
||||
// admin – alles, inkl. Mitglieder hinzufügen/löschen, Gruppe importieren, Patches, Statistik-Korrektur
|
||||
// mitglied – Kalender, Fahrtplaner, Treffpunkte, Mitglieder bearbeiten (nicht hinzufügen/löschen)
|
||||
|
||||
import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
||||
@@ -17,6 +21,10 @@ 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;
|
||||
export const ROLES = ['admin', 'mitglied'];
|
||||
|
||||
// Konten aus der Zeit vor den Rollen wurden vom Betreiber angelegt → Admin, damit sich niemand aussperrt
|
||||
export const roleOf = (user) => (ROLES.includes(user?.role) ? user.role : 'admin');
|
||||
|
||||
// ---------- Passwort-Hashing (scrypt) ----------
|
||||
const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 };
|
||||
@@ -105,7 +113,7 @@ function setCookie(req, res, value, maxAgeSec) {
|
||||
res.setHeader('Set-Cookie', attrs.join('; '));
|
||||
}
|
||||
|
||||
// Liefert den angemeldeten Benutzernamen oder null
|
||||
// Liefert den angemeldeten Benutzer ({ name, role }) oder null
|
||||
function sessionUser(req) {
|
||||
const token = readCookie(req, COOKIE);
|
||||
if (!token) return null;
|
||||
@@ -114,7 +122,7 @@ function sessionUser(req) {
|
||||
// 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;
|
||||
return { name: user.name, role: roleOf(user) };
|
||||
}
|
||||
|
||||
// ---------- Schutz vor Passwort-Raten ----------
|
||||
@@ -156,7 +164,7 @@ export function authRoutes(app) {
|
||||
});
|
||||
saveSessions();
|
||||
setCookie(req, res, token, SESSION_DAYS * 86400);
|
||||
res.json({ user: user.name });
|
||||
res.json({ user: user.name, role: roleOf(user) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -170,15 +178,24 @@ export function authRoutes(app) {
|
||||
});
|
||||
|
||||
app.get('/api/me', (req, res) => {
|
||||
res.json({ user: sessionUser(req) });
|
||||
const user = sessionUser(req);
|
||||
res.json({ user: user?.name ?? null, role: user?.role ?? null });
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
const user = sessionUser(req);
|
||||
req.user = user?.name ?? null;
|
||||
req.role = user?.role ?? null;
|
||||
if (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();
|
||||
}
|
||||
|
||||
// Nur für Admins (nach requireAuth verwenden)
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.role === 'admin') return next();
|
||||
res.status(403).json({ error: 'Das darf nur der Admin-Zugang.' });
|
||||
}
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
<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>
|
||||
<p id="trip-members-empty" class="muted" hidden>Noch keine Mitglieder. Der Admin legt sie unter <a href="/gruppe">Gruppe</a> an.</p>
|
||||
<ul id="trip-members" class="list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-3
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="split wide-right">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card" id="member-form-card">
|
||||
<h2 id="member-form-title">Mitglied hinzufügen</h2>
|
||||
<form id="member-form">
|
||||
<input type="hidden" id="member-id">
|
||||
@@ -75,15 +75,16 @@
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<h2>Gruppe</h2>
|
||||
<p id="members-role-hint" class="muted" hidden>Du kannst Mitglieder bearbeiten.</p>
|
||||
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
|
||||
<ul id="members-list" class="list"></ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Daten sichern</h2>
|
||||
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
|
||||
<p class="muted" id="backup-text">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
|
||||
<label class="button" id="import-btn">Importieren
|
||||
<input type="file" id="import-file" accept="application/json" hidden>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+21
-5
@@ -1,7 +1,10 @@
|
||||
import { $, api, esc, sortByName, seatsLabel, showError, renderHeader } from './common.js';
|
||||
import { $, api, esc, sortByName, seatsLabel, showError, renderHeader, isAdmin } from './common.js';
|
||||
|
||||
renderHeader('gruppe');
|
||||
|
||||
// Admin: Mitglieder hinzufügen, löschen, importieren. Mitglied-Zugang: nur bearbeiten.
|
||||
// Der Server prüft das ebenfalls – hier wird nur ausgeblendet, was ohnehin abgelehnt würde.
|
||||
let admin = false;
|
||||
let members = [];
|
||||
let points = [];
|
||||
const form = $('#member-form');
|
||||
@@ -31,6 +34,8 @@ function resetForm() {
|
||||
$('#member-seats-wrap').hidden = true;
|
||||
$('#member-cancel').hidden = true;
|
||||
$('#member-form-title').textContent = 'Mitglied hinzufügen';
|
||||
// Ohne Admin-Rechte gibt es das Formular nur zum Bearbeiten
|
||||
$('#member-form-card').hidden = !admin;
|
||||
}
|
||||
|
||||
$('#member-cancel').addEventListener('click', resetForm);
|
||||
@@ -70,8 +75,9 @@ function edit(id) {
|
||||
$('#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' });
|
||||
$('#member-form-card').hidden = false;
|
||||
$('#member-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
$('#member-name').focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
@@ -98,7 +104,7 @@ function render() {
|
||||
<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>
|
||||
${admin ? `<button class="link danger" data-del="${m.id}">Löschen</button>` : ''}
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
@@ -213,4 +219,14 @@ async function load() {
|
||||
render();
|
||||
}
|
||||
|
||||
load().then(loadPoints).catch(showError);
|
||||
async function init() {
|
||||
admin = await isAdmin();
|
||||
$('#members-role-hint').hidden = admin;
|
||||
$('#import-btn').hidden = !admin;
|
||||
if (!admin) $('#backup-text').textContent = 'Gruppe als JSON-Datei sichern.';
|
||||
resetForm();
|
||||
await load();
|
||||
await loadPoints();
|
||||
}
|
||||
|
||||
init().catch(showError);
|
||||
@@ -12,7 +12,6 @@
|
||||
<div>
|
||||
<span class="eyebrow">Spieltage & Termine</span>
|
||||
<h1>Kalender</h1>
|
||||
<p>Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button" href="/api/calendar.ics" download title="Alle kommenden Termine als .ics-Datei für Google, Apple oder Outlook">
|
||||
@@ -46,7 +45,7 @@
|
||||
<select id="me-select"></select>
|
||||
</label>
|
||||
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Termine eintragen und zusagen kannst.
|
||||
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
|
||||
Du fehlst in der Liste? Dann lass dich vom Admin unter <a href="/gruppe">Gruppe</a> eintragen.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -61,7 +60,7 @@
|
||||
<input type="checkbox" id="event-game" checked>
|
||||
<div>
|
||||
<strong>Spiel-Event</strong>
|
||||
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen, Grillen oder Basteltage das Häkchen entfernen.</span>
|
||||
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen oder Grillen das Häkchen entfernen.</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grid2">
|
||||
|
||||
+82
-1
@@ -858,6 +858,84 @@ button.small, .button.small { padding: 5px 11px; font-size: .82rem; }
|
||||
}
|
||||
.empty-state svg { width: 56px; height: 56px; color: var(--border); }
|
||||
|
||||
/* ---------- Kooperationen ---------- */
|
||||
.coop-grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); }
|
||||
.coop-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%);
|
||||
transition: background-color .2s;
|
||||
}
|
||||
.coop-card:hover { background: var(--card-2); }
|
||||
.coop-card-img {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: repeating-linear-gradient(135deg, var(--flag) 0 18px, #303b6a 18px 36px);
|
||||
}
|
||||
.coop-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s var(--ease-out); }
|
||||
.coop-card:hover .coop-card-img img { transform: scale(1.05); }
|
||||
.coop-initials { font-family: var(--font-stencil); font-size: 3.4rem; color: var(--accent); letter-spacing: .1em; }
|
||||
.coop-card-body { padding: 16px 18px 18px; display: grid; gap: 4px; }
|
||||
.coop-card-body h2 { margin: 0; font-size: 1.4rem; font-weight: 700; }
|
||||
.coop-card-body p { margin: 0; }
|
||||
.coop-card .go { margin-top: 8px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .16em; font-size: .85rem; color: var(--accent); }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 14px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .14em; text-decoration: none; }
|
||||
.coop-hero {
|
||||
position: relative;
|
||||
aspect-ratio: 21 / 9;
|
||||
max-height: 62vh;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
background: repeating-linear-gradient(135deg, var(--flag) 0 22px, #303b6a 22px 44px);
|
||||
clip-path: polygon(0 0, calc(100% - 28px) 0, 100% 28px, 100% 100%, 28px 100%, 0 calc(100% - 28px));
|
||||
}
|
||||
.coop-hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
/* Abdunklung nur unten, damit der Name auf jedem Foto lesbar bleibt */
|
||||
.coop-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(13, 17, 38, .9) 0%, rgba(13, 17, 38, .35) 40%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.coop-hero.no-image::before {
|
||||
content: attr(data-initials);
|
||||
position: absolute;
|
||||
right: 6%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-stencil);
|
||||
font-size: clamp(5rem, 16vw, 12rem);
|
||||
color: var(--accent);
|
||||
opacity: .35;
|
||||
}
|
||||
.coop-hero-text { position: absolute; left: clamp(16px, 3vw, 40px); right: clamp(16px, 3vw, 40px); bottom: clamp(16px, 3vw, 36px); z-index: 1; }
|
||||
.coop-hero-text h1 { margin: 0; font-size: clamp(2rem, 6vw, 4.4rem); font-weight: 700; letter-spacing: .03em; text-shadow: 0 4px 24px rgba(0, 0, 0, .5); }
|
||||
@media (max-width: 600px) { .coop-hero { aspect-ratio: 4 / 3; } }
|
||||
|
||||
.coop-info .card { margin: 0; }
|
||||
.coop-description { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 70ch; margin: 0; line-height: 1.65; }
|
||||
.coop-leader { margin: 0; font-family: var(--font-head); font-size: 1.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.card h2.spaced { margin-top: 24px; }
|
||||
.coop-socials { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.coop-socials svg { width: 18px; height: 18px; }
|
||||
.admin-actions { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.socials-fields { border: none; padding: 0; margin: 4px 0 8px; }
|
||||
.socials-fields legend { font-size: .94rem; color: var(--muted); padding: 0; margin-bottom: 6px; }
|
||||
.socials-fields .grid2 { gap: 0 12px; }
|
||||
.dropzone.wide { min-height: 220px; }
|
||||
.dropzone.wide img { max-height: 260px; width: 100%; object-fit: cover; }
|
||||
|
||||
/* ---------- Fahrtplaner ---------- */
|
||||
.cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; margin-top: 14px; }
|
||||
.car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
@@ -901,7 +979,10 @@ footer .f-brand img { width: 32px; height: 32px; }
|
||||
/* ---------- Responsiv ---------- */
|
||||
.nav-toggle { display: none; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
/* Mittlere Breite: Zusatz neben dem Logo weg, damit alle Menüpunkte in eine Zeile passen */
|
||||
@media (max-width: 1280px) { .brand small { display: none; } }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.site-header { padding: 8px var(--gutter); }
|
||||
.site-header .inner { gap: 8px; }
|
||||
.nav-toggle {
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
<h2>Highcap Rasselbande</h2>
|
||||
<p>
|
||||
Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin.
|
||||
Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong>, dort sind wir regelmäßig unterwegs.
|
||||
</p>
|
||||
<p>
|
||||
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
|
||||
|
||||
+37
-3
@@ -81,6 +81,7 @@ const NAV = [
|
||||
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
|
||||
{ href: '/statistik', key: 'statistik', label: 'Statistik' },
|
||||
{ href: '/patches', key: 'patches', label: 'Patches' },
|
||||
{ href: '/kooperationen', key: 'kooperationen', label: 'Kooperationen' },
|
||||
];
|
||||
|
||||
// overlay: Kopfzeile liegt durchsichtig über dem Hero und wird erst beim Scrollen fest (Startseite)
|
||||
@@ -129,13 +130,20 @@ export function renderFooter() {
|
||||
document.body.append(footer);
|
||||
}
|
||||
|
||||
// Einmal pro Seite abfragen, ob jemand angemeldet ist
|
||||
// Einmal pro Seite abfragen, wer angemeldet ist: { name, role } oder null.
|
||||
// role: 'admin' oder 'mitglied'. Die Rechte prüft der Server – hier geht es nur darum, was angezeigt wird.
|
||||
let mePromise;
|
||||
export function currentUser() {
|
||||
mePromise ??= api('GET', '/me').then(({ user }) => user || null).catch(() => null);
|
||||
mePromise ??= api('GET', '/me')
|
||||
.then(({ user, role }) => (user ? { name: user, role } : null))
|
||||
.catch(() => null);
|
||||
return mePromise;
|
||||
}
|
||||
|
||||
export async function isAdmin() {
|
||||
return (await currentUser())?.role === 'admin';
|
||||
}
|
||||
|
||||
async function renderAuthLink(nav) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
@@ -145,7 +153,7 @@ async function renderAuthLink(nav) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Abmelden';
|
||||
btn.title = `Angemeldet als ${user}`;
|
||||
btn.title = `Angemeldet als ${user.name} (${user.role === 'admin' ? 'Admin' : 'Mitglied'})`;
|
||||
btn.addEventListener('click', async () => {
|
||||
await api('POST', '/logout').catch(() => {});
|
||||
location.href = '/';
|
||||
@@ -158,3 +166,29 @@ export function formatTime(ev) {
|
||||
if (!ev.time) return '';
|
||||
return `${ev.time}${ev.endTime ? `–${ev.endTime}` : ''} Uhr`;
|
||||
}
|
||||
|
||||
// ---------- Bild-Upload ----------
|
||||
// Bild im Browser auf höchstens maxSize px verkleinern; Transparenz bleibt (WebP bzw. PNG).
|
||||
// Liefert eine data:-URL für die API.
|
||||
export async function prepareImage(file, maxSize = 1600) {
|
||||
// SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe)
|
||||
if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) {
|
||||
if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).');
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return `data:image/svg+xml;base64,${btoa(bin)}`;
|
||||
}
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close?.();
|
||||
let url = canvas.toDataURL('image/webp', 0.9);
|
||||
// Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner
|
||||
if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88);
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js';
|
||||
|
||||
renderHeader('kooperationen');
|
||||
renderFooter();
|
||||
|
||||
let coops = [];
|
||||
let admin = false; // nur der Admin-Zugang darf anlegen, ändern, löschen
|
||||
let editingId = null;
|
||||
let imageData = null; // data:-URL des neu gewählten Titelbildes
|
||||
|
||||
const imgUrl = (c) => `/koop-img/${encodeURIComponent(c.image)}`;
|
||||
|
||||
// ---------- Soziale Medien ----------
|
||||
const SOCIALS = [
|
||||
{ key: 'website', label: 'Website', icon: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/>' },
|
||||
{ key: 'instagram', label: 'Instagram', icon: '<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.5" cy="6.5" r=".9" fill="currentColor" stroke="none"/>' },
|
||||
{ key: 'youtube', label: 'YouTube', icon: '<rect x="2" y="5" width="20" height="14" rx="4"/><path d="M10 9l5 3-5 3z" fill="currentColor"/>' },
|
||||
{ key: 'tiktok', label: 'TikTok', icon: '<path d="M14 3v11.5a3.5 3.5 0 1 1-3.5-3.5"/><path d="M14 3c.4 2.6 2.2 4.4 5 4.6"/>' },
|
||||
{ key: 'facebook', label: 'Facebook', icon: '<path d="M14.5 21v-8h3l.5-3.5h-3.5V7.6c0-1 .4-1.7 1.8-1.7H18V2.8c-.4 0-1.6-.2-2.9-.2-2.8 0-4.6 1.7-4.6 4.8v2.1H7.5V13h3v8"/>' },
|
||||
{ key: 'discord', label: 'Discord', icon: '<path d="M7 7.5c3.3-1.3 6.7-1.3 10 0 1.7 2.5 2.6 5.3 2.5 8.5-1.5 1.2-3 1.9-4.6 2.3l-1-1.8M7 7.5C5.3 10 4.4 12.8 4.5 16c1.5 1.2 3 1.9 4.6 2.3l1-1.8M8 15.5c2.7 1.2 5.3 1.2 8 0"/><circle cx="9.5" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="14.5" cy="12" r="1.1" fill="currentColor" stroke="none"/>' },
|
||||
];
|
||||
|
||||
const icon = (s) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${s.icon}</svg>`;
|
||||
|
||||
function socialsHtml(c) {
|
||||
const links = SOCIALS.filter((s) => c.socials?.[s.key]);
|
||||
return links.length
|
||||
? links.map((s) => `<a class="button" href="${esc(c.socials[s.key])}" target="_blank" rel="noopener noreferrer">${icon(s)}${s.label}</a>`).join('')
|
||||
: '<p class="muted">Keine Links eingetragen.</p>';
|
||||
}
|
||||
|
||||
// Ohne Titelbild: Anfangsbuchstaben auf Flaggenblau
|
||||
const initials = (name) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
|
||||
|
||||
// ---------- Übersicht ----------
|
||||
function renderOverview() {
|
||||
$('#coop-empty').hidden = coops.length > 0;
|
||||
$('#coop-empty-hint').textContent = admin
|
||||
? 'Trag über „Kooperation hinzufügen“ die erste Gruppe ein.'
|
||||
: 'Sobald der Admin eine Kooperation einträgt, erscheint sie hier.';
|
||||
$('#coop-grid').innerHTML = coops.map((c) => `
|
||||
<a class="coop-card" href="#${c.id}">
|
||||
<div class="coop-card-img">
|
||||
${c.image ? `<img src="${imgUrl(c)}" alt="" loading="lazy">` : `<span class="coop-initials">${esc(initials(c.name))}</span>`}
|
||||
</div>
|
||||
<div class="coop-card-body">
|
||||
<h2>${esc(c.name)}</h2>
|
||||
${c.leader ? `<p class="muted">Anführer: ${esc(c.leader)}</p>` : ''}
|
||||
<span class="go">Ansehen</span>
|
||||
</div>
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
// ---------- Einzelansicht (#id) ----------
|
||||
function renderDetail(c) {
|
||||
document.title = `${c.name} – Kooperationen – HCRB`;
|
||||
$('#detail-name').textContent = c.name;
|
||||
const img = $('#detail-img');
|
||||
img.hidden = !c.image;
|
||||
if (c.image) img.src = imgUrl(c);
|
||||
$('#detail-hero').classList.toggle('no-image', !c.image);
|
||||
$('#detail-hero').dataset.initials = initials(c.name);
|
||||
$('#detail-description').textContent = c.description || 'Noch keine Beschreibung.';
|
||||
$('#detail-leader').textContent = c.leader || 'Nicht angegeben';
|
||||
$('#detail-socials').innerHTML = socialsHtml(c);
|
||||
$('#detail-admin').hidden = !admin;
|
||||
}
|
||||
|
||||
function route() {
|
||||
const id = location.hash.slice(1);
|
||||
const coop = coops.find((c) => c.id === id);
|
||||
$('#overview').hidden = !!coop;
|
||||
$('#detail').hidden = !coop;
|
||||
if (coop) {
|
||||
renderDetail(coop);
|
||||
scrollTo(0, 0);
|
||||
} else {
|
||||
document.title = 'Kooperationen – HCRB';
|
||||
renderOverview();
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener('hashchange', () => {
|
||||
closeForm();
|
||||
route();
|
||||
});
|
||||
|
||||
$('#back-link').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
history.pushState(null, '', location.pathname);
|
||||
route();
|
||||
});
|
||||
|
||||
// ---------- Formular ----------
|
||||
function showPreview(src) {
|
||||
$('#coop-preview').hidden = !src;
|
||||
if (src) $('#coop-preview').src = src;
|
||||
$('#drop-text').innerHTML = src
|
||||
? '<strong>Anderes Titelbild wählen</strong>'
|
||||
: '<strong>Titelbild auswählen</strong><br>oder hierher ziehen · am besten ein breites Foto (Querformat)';
|
||||
}
|
||||
|
||||
function openForm(coop) {
|
||||
editingId = coop?.id ?? null;
|
||||
imageData = null;
|
||||
$('#coop-form').reset();
|
||||
$('#coop-error').hidden = true;
|
||||
$('#coop-form-title').textContent = coop ? 'Kooperation bearbeiten' : 'Kooperation hinzufügen';
|
||||
$('#coop-name').value = coop?.name ?? '';
|
||||
$('#coop-leader').value = coop?.leader ?? '';
|
||||
$('#coop-description').value = coop?.description ?? '';
|
||||
for (const s of SOCIALS) $(`#social-${s.key}`).value = coop?.socials?.[s.key] ?? '';
|
||||
$('#remove-image-wrap').hidden = !coop?.image;
|
||||
showPreview(coop?.image ? imgUrl(coop) : null);
|
||||
$('#coop-form-card').hidden = false;
|
||||
$('#coop-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
$('#coop-name').focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
$('#coop-form-card').hidden = true;
|
||||
editingId = null;
|
||||
imageData = null;
|
||||
}
|
||||
|
||||
async function pickFile(file) {
|
||||
if (!file) return;
|
||||
$('#coop-error').hidden = true;
|
||||
try {
|
||||
imageData = await prepareImage(file, 2000);
|
||||
showPreview(imageData);
|
||||
$('#coop-remove-image').checked = false;
|
||||
} catch (err) {
|
||||
imageData = null;
|
||||
$('#coop-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.';
|
||||
$('#coop-error').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('#coop-file').addEventListener('change', (e) => pickFile(e.target.files[0]));
|
||||
|
||||
const drop = $('#dropzone');
|
||||
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
||||
drop.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
drop.classList.remove('over');
|
||||
pickFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
$('#add-btn').addEventListener('click', () => openForm(null));
|
||||
$('#coop-cancel').addEventListener('click', closeForm);
|
||||
$('#detail-edit').addEventListener('click', () => openForm(coops.find((c) => c.id === location.hash.slice(1))));
|
||||
|
||||
$('#detail-delete').addEventListener('click', async () => {
|
||||
const c = coops.find((x) => x.id === location.hash.slice(1));
|
||||
if (!c || !confirm(`Kooperation „${c.name}“ wirklich löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/kooperationen/${c.id}`);
|
||||
history.pushState(null, '', location.pathname);
|
||||
await load();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
$('#coop-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const error = $('#coop-error');
|
||||
error.hidden = true;
|
||||
const button = $('#coop-submit');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const body = {
|
||||
name: $('#coop-name').value,
|
||||
leader: $('#coop-leader').value,
|
||||
description: $('#coop-description').value,
|
||||
socials: Object.fromEntries(SOCIALS.map((s) => [s.key, $(`#social-${s.key}`).value])),
|
||||
};
|
||||
if (imageData) body.image = imageData;
|
||||
else if ($('#coop-remove-image').checked) body.removeImage = true;
|
||||
const saved = editingId
|
||||
? await api('PUT', `/kooperationen/${editingId}`, body)
|
||||
: await api('POST', '/kooperationen', body);
|
||||
closeForm();
|
||||
coops = await api('GET', '/kooperationen');
|
||||
// Nach dem Speichern die Seite der Kooperation zeigen
|
||||
if (location.hash.slice(1) === saved.id) route();
|
||||
else location.hash = saved.id;
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Laden ----------
|
||||
async function load() {
|
||||
coops = await api('GET', '/kooperationen');
|
||||
route();
|
||||
}
|
||||
|
||||
admin = await isAdmin();
|
||||
$('#add-btn').hidden = !admin;
|
||||
showPreview(null);
|
||||
await load().catch(showError);
|
||||
+7
-30
@@ -1,10 +1,10 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js';
|
||||
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js';
|
||||
|
||||
renderHeader('patches');
|
||||
renderFooter();
|
||||
|
||||
let patches = [];
|
||||
let loggedIn = false;
|
||||
let admin = false; // nur der Admin-Zugang darf Patches hochladen, ändern, löschen
|
||||
let editingId = null;
|
||||
let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes
|
||||
|
||||
@@ -13,9 +13,9 @@ const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`;
|
||||
// ---------- Galerie ----------
|
||||
function render() {
|
||||
$('#patch-empty').hidden = patches.length > 0;
|
||||
$('#patch-empty-hint').textContent = loggedIn
|
||||
$('#patch-empty-hint').textContent = admin
|
||||
? 'Lade über „Patch hochladen“ den ersten Patch hoch.'
|
||||
: 'Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.';
|
||||
: 'Sobald der Admin einen Patch hochlädt, erscheint er hier.';
|
||||
for (const status of ['vorhanden', 'geplant']) {
|
||||
const list = patches.filter((p) => p.status === status);
|
||||
$(`#group-${status}`).hidden = list.length === 0;
|
||||
@@ -34,7 +34,7 @@ function patchHtml(p) {
|
||||
${p.status === 'geplant' ? '<span class="badge nogame">In Planung</span>' : ''}
|
||||
<h3>${esc(p.name)}</h3>
|
||||
${p.description ? `<p>${esc(p.description)}</p>` : ''}
|
||||
${loggedIn ? `
|
||||
${admin ? `
|
||||
<div class="actions">
|
||||
<button class="link" data-edit="${p.id}">Bearbeiten</button>
|
||||
<button class="link danger" data-delete="${p.id}">Löschen</button>
|
||||
@@ -107,29 +107,6 @@ function showPreview(src) {
|
||||
: '<strong>Bild auswählen</strong><br>oder hierher ziehen · PNG, JPEG, WebP oder SVG';
|
||||
}
|
||||
|
||||
// Bild im Browser auf höchstens 1600 px verkleinern; Transparenz bleibt (WebP bzw. PNG)
|
||||
async function prepareImage(file) {
|
||||
// SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe)
|
||||
if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) {
|
||||
if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).');
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return `data:image/svg+xml;base64,${btoa(bin)}`;
|
||||
}
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close?.();
|
||||
let url = canvas.toDataURL('image/webp', 0.9);
|
||||
// Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner
|
||||
if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function pickFile(file) {
|
||||
if (!file) return;
|
||||
@@ -195,8 +172,8 @@ async function load() {
|
||||
render();
|
||||
}
|
||||
|
||||
loggedIn = !!(await currentUser());
|
||||
$('#add-btn').hidden = !loggedIn;
|
||||
admin = await isAdmin();
|
||||
$('#add-btn').hidden = !admin;
|
||||
await load().catch(showError);
|
||||
// Link von der Startseite (/patches#id) springt zum Patch
|
||||
if (location.hash) document.getElementById(location.hash.slice(1))?.scrollIntoView({ block: 'center' });
|
||||
@@ -1,10 +1,10 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
|
||||
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin } from './common.js';
|
||||
|
||||
renderHeader('statistik');
|
||||
renderFooter();
|
||||
|
||||
let stats = [];
|
||||
let canEdit = false; // angemeldet und laufendes Jahr
|
||||
let canEdit = false; // Admin und laufendes Jahr
|
||||
|
||||
function detail(s) {
|
||||
if (!s.manual) return '';
|
||||
@@ -50,12 +50,12 @@ $('#stats-list').addEventListener('click', async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
let loggedIn = false;
|
||||
let admin = false;
|
||||
|
||||
async function load(year) {
|
||||
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
|
||||
stats = data.entries;
|
||||
canEdit = loggedIn && data.live;
|
||||
canEdit = admin && 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
|
||||
@@ -66,6 +66,6 @@ async function load(year) {
|
||||
|
||||
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
|
||||
|
||||
api('GET', '/me')
|
||||
.then(({ user }) => { loggedIn = !!user; return load(); })
|
||||
isAdmin()
|
||||
.then((yes) => { admin = yes; return load(); })
|
||||
.catch(showError);
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kooperationen – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<!-- Übersicht aller Kooperationen -->
|
||||
<section id="overview">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<span class="eyebrow">Verbündete Teams</span>
|
||||
<h1>Kooperationen</h1>
|
||||
<p>Gruppen, mit denen die HCRB zusammen spielt, plant und unterwegs ist.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="add-btn" class="primary" hidden>Kooperation hinzufügen</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="coop-grid" class="coop-grid"></div>
|
||||
<div id="coop-empty" class="empty-state" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M7 11l3-3 3 2 3-3 4 4-6 6-2-1-2 1-6-6z"/><path d="M3 9l4 2M21 9l-4 2"/></svg>
|
||||
<strong>Noch keine Kooperationen</strong>
|
||||
<span id="coop-empty-hint">Sobald der Admin eine Kooperation einträgt, erscheint sie hier.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Einzelne Kooperation: Titelbild, Anführer, Soziale Medien, Beschreibung -->
|
||||
<article id="detail" hidden>
|
||||
<a href="#" class="back-link" id="back-link">‹ Alle Kooperationen</a>
|
||||
<div class="coop-hero" id="detail-hero">
|
||||
<img id="detail-img" alt="" hidden>
|
||||
<div class="coop-hero-text">
|
||||
<span class="eyebrow">Kooperation</span>
|
||||
<h1 id="detail-name"></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split wide-left coop-info">
|
||||
<div class="card">
|
||||
<h2>Über die Gruppe</h2>
|
||||
<p id="detail-description" class="coop-description"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Anführer</h2>
|
||||
<p id="detail-leader" class="coop-leader"></p>
|
||||
<h2 class="spaced">Soziale Medien</h2>
|
||||
<div id="detail-socials" class="coop-socials"></div>
|
||||
<div id="detail-admin" class="row wrap admin-actions" hidden>
|
||||
<button type="button" id="detail-edit">Bearbeiten</button>
|
||||
<button type="button" id="detail-delete" class="link danger">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Formular (nur Admin) -->
|
||||
<div class="card" id="coop-form-card" hidden>
|
||||
<h2 id="coop-form-title">Kooperation hinzufügen</h2>
|
||||
<form id="coop-form" class="split">
|
||||
<div>
|
||||
<label class="dropzone wide" id="dropzone" for="coop-file">
|
||||
<img id="coop-preview" alt="Vorschau" hidden>
|
||||
<span id="drop-text"></span>
|
||||
<input type="file" id="coop-file" accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg" hidden>
|
||||
</label>
|
||||
<label class="inline" id="remove-image-wrap" hidden>
|
||||
<input type="checkbox" id="coop-remove-image"> Titelbild entfernen
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Name der Gruppe
|
||||
<input type="text" id="coop-name" required maxlength="80" placeholder="z. B. Black Wolves Airsoft">
|
||||
</label>
|
||||
<label>Anführer
|
||||
<input type="text" id="coop-leader" maxlength="120" placeholder="Name oder Rufname">
|
||||
</label>
|
||||
<label>Kurze Beschreibung
|
||||
<textarea id="coop-description" maxlength="2000" placeholder="Wer sind sie, wo spielen sie, was machen wir zusammen …"></textarea>
|
||||
</label>
|
||||
<fieldset class="socials-fields">
|
||||
<legend>Soziale Medien (optional, Link einfügen)</legend>
|
||||
<div class="grid2">
|
||||
<label>Website <input type="text" id="social-website" inputmode="url" placeholder="https://…"></label>
|
||||
<label>Instagram <input type="text" id="social-instagram" inputmode="url" placeholder="instagram.com/…"></label>
|
||||
<label>YouTube <input type="text" id="social-youtube" inputmode="url" placeholder="youtube.com/@…"></label>
|
||||
<label>TikTok <input type="text" id="social-tiktok" inputmode="url" placeholder="tiktok.com/@…"></label>
|
||||
<label>Facebook <input type="text" id="social-facebook" inputmode="url" placeholder="facebook.com/…"></label>
|
||||
<label>Discord <input type="text" id="social-discord" inputmode="url" placeholder="discord.gg/…"></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p id="coop-error" class="warn" hidden></p>
|
||||
<div class="row wrap">
|
||||
<button type="submit" class="primary" id="coop-submit">Speichern</button>
|
||||
<button type="button" id="coop-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/kooperationen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
<div id="patch-empty" class="empty-state" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 2.5l8 3.5v6c0 5-3.5 8.2-8 9.5-4.5-1.3-8-4.5-8-9.5V6z"/></svg>
|
||||
<strong>Noch keine Patches</strong>
|
||||
<span id="patch-empty-hint">Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.</span>
|
||||
<span id="patch-empty-hint">Sobald der Admin einen Patch hochlädt, erscheint er hier.</span>
|
||||
</div>
|
||||
|
||||
<dialog class="lightbox" id="lightbox">
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
<h2>So wird gezählt</h2>
|
||||
<ul class="facts">
|
||||
<li>Jeder Termin im Kalender, der als <strong>Spiel-Event</strong> markiert ist und bei dem du zugesagt hast, zählt als ein Spiel, sobald der Tag vorbei oder heute ist.</li>
|
||||
<li>Termine ohne Spiel (Treffen, Grillen, Basteltage) zählen nicht.</li>
|
||||
<li>Externe werden nicht aufgeführt.</li>
|
||||
<li>Termine ohne Spiel (Treffen, Grillen) zählen nicht.</li>
|
||||
<li>Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+31
-10
@@ -1,12 +1,15 @@
|
||||
// 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
|
||||
// Die Konten sind gemeinsame Zugänge (z. B. "admin" und "hcrb" für alle Mitglieder), keine persönlichen.
|
||||
//
|
||||
// npm run user -- list alle Benutzer mit Rolle
|
||||
// npm run user -- add <name> [admin|mitglied] Benutzer anlegen (Standard: mitglied, Passwort wird abgefragt)
|
||||
// npm run user -- role <name> <admin|mitglied> Rolle ändern (meldet alle Sitzungen des Benutzers ab)
|
||||
// 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';
|
||||
import { hashPassword, readUsers, writeUsers, normalizeName, MIN_PASSWORD, USERS_FILE, ROLES, roleOf } from '../auth.js';
|
||||
|
||||
// Passwort verdeckt einlesen (bei umgeleiteter Eingabe zeilenweise)
|
||||
let pipedLines;
|
||||
@@ -52,22 +55,40 @@ async function newPassword() {
|
||||
return hashPassword(pw);
|
||||
}
|
||||
|
||||
const [cmd, rawName] = process.argv.slice(2);
|
||||
const [cmd, rawName, rawRole] = process.argv.slice(2);
|
||||
const role = String(rawRole ?? '').trim().toLowerCase();
|
||||
|
||||
function checkRole(value) {
|
||||
if (!ROLES.includes(value)) throw new Error(`Rolle muss ${ROLES.join(' oder ')} sein.`);
|
||||
return value;
|
||||
}
|
||||
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.');
|
||||
console.log(users.length
|
||||
? users.map((u) => `${u.name.padEnd(20)} ${roleOf(u)}${u.role ? '' : ' (noch ohne Rolle gespeichert)'}`).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() });
|
||||
const newRole = checkRole(role || 'mitglied');
|
||||
users.push({ name, role: newRole, hash: await newPassword(), changedAt: Date.now() });
|
||||
writeUsers(users);
|
||||
console.log(`Benutzer „${name}“ angelegt.`);
|
||||
console.log(`Benutzer „${name}“ als ${newRole} angelegt.`);
|
||||
} else if (cmd === 'role' && name && role) {
|
||||
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
|
||||
user.role = checkRole(role);
|
||||
if (user.role === 'mitglied' && !users.some((u) => roleOf(u) === 'admin')) {
|
||||
throw new Error('Das ist der letzte Admin. Lege zuerst einen anderen Admin an.');
|
||||
}
|
||||
user.changedAt = Date.now();
|
||||
writeUsers(users);
|
||||
console.log(`„${name}“ ist jetzt ${user.role}. Bestehende Anmeldungen sind abgemeldet.`);
|
||||
} else if (cmd === 'passwd' && name) {
|
||||
if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`);
|
||||
user.hash = await newPassword();
|
||||
@@ -79,7 +100,7 @@ try {
|
||||
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('Benutzung: npm run user -- list | add <name> [admin|mitglied] | role <name> <admin|mitglied> | passwd <name> | remove <name>');
|
||||
console.log(`Benutzerdatei: ${USERS_FILE}`);
|
||||
process.exitCode = cmd ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ import { randomUUID, createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, extname, normalize, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { authRoutes, requireAuth } from './auth.js';
|
||||
import { authRoutes, requireAuth, requireAdmin } from './auth.js';
|
||||
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(ROOT, 'data');
|
||||
const DB_FILE = join(DATA_DIR, 'db.json');
|
||||
const PATCH_DIR = join(DATA_DIR, 'patches');
|
||||
const KOOP_DIR = join(DATA_DIR, 'kooperationen');
|
||||
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: [], patches: [] };
|
||||
if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [], patches: [], cooperations: [] };
|
||||
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
|
||||
@@ -30,6 +31,7 @@ function loadDb() {
|
||||
statsArchive: db.statsArchive || [],
|
||||
// Ältere Patches haben keinen Status – die gab es schon
|
||||
patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })),
|
||||
cooperations: db.cooperations || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,15 +136,44 @@ function cleanPatch(body) {
|
||||
return { name: name.slice(0, 80), description: description.slice(0, 500), status };
|
||||
}
|
||||
|
||||
function savePatchImage({ buf, ext }) {
|
||||
mkdirSync(PATCH_DIR, { recursive: true });
|
||||
// Hochgeladene Bilder bekommen einen zufälligen Namen (kein Raten, keine Pfadtricks)
|
||||
function saveImage(dir, { buf, ext }) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = `${randomUUID()}.${ext}`;
|
||||
writeFileSync(join(PATCH_DIR, file), buf);
|
||||
writeFileSync(join(dir, file), buf);
|
||||
return file;
|
||||
}
|
||||
|
||||
function removePatchImage(file) {
|
||||
try { unlinkSync(join(PATCH_DIR, file)); } catch { /* schon weg */ }
|
||||
function removeImage(dir, file) {
|
||||
if (!file) return;
|
||||
try { unlinkSync(join(dir, file)); } catch { /* schon weg */ }
|
||||
}
|
||||
|
||||
// ---------- Kooperationen ----------
|
||||
const SOCIALS = ['website', 'instagram', 'youtube', 'tiktok', 'facebook', 'discord'];
|
||||
|
||||
function cleanUrl(value, label) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return null;
|
||||
// "instagram.com/xyz" ohne https:// ist bequem – wird ergänzt
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
||||
let url;
|
||||
try { url = new URL(withScheme); } catch { throw new BadRequest(`${label}: keine gültige Adresse.`); }
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new BadRequest(`${label}: nur http(s)-Adressen.`);
|
||||
return url.href.slice(0, 300);
|
||||
}
|
||||
|
||||
function cleanCooperation(body) {
|
||||
const name = String(body?.name ?? '').trim();
|
||||
if (!name) throw new BadRequest('Name der Gruppe fehlt.');
|
||||
const leader = String(body.leader ?? '').trim();
|
||||
const description = String(body.description ?? '').trim();
|
||||
const socials = {};
|
||||
for (const key of SOCIALS) {
|
||||
const url = cleanUrl(body.socials?.[key], key[0].toUpperCase() + key.slice(1));
|
||||
if (url) socials[key] = url;
|
||||
}
|
||||
return { name: name.slice(0, 80), leader: leader.slice(0, 120), description: description.slice(0, 2000), socials };
|
||||
}
|
||||
|
||||
function cleanMeetingPoint(body) {
|
||||
@@ -225,7 +256,7 @@ app.disable('x-powered-by');
|
||||
// Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal"
|
||||
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
|
||||
// Patch-Uploads enthalten ein Bild, alles andere bleibt klein
|
||||
app.use('/api/patches', express.json({ limit: '12mb' }));
|
||||
app.use(['/api/patches', '/api/kooperationen'], express.json({ limit: '12mb' }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Öffentlich: Startseite, Login, CSS/Bilder
|
||||
@@ -233,8 +264,8 @@ app.use(versioned(join(ROOT, 'public'), 'no-cache'));
|
||||
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
|
||||
authRoutes(app);
|
||||
|
||||
// Patch-Bilder sind öffentlich (Dateinamen sind zufällige IDs)
|
||||
app.use('/patch-img', express.static(PATCH_DIR, {
|
||||
// Hochgeladene Bilder sind öffentlich (Dateinamen sind zufällige IDs)
|
||||
const imageStatic = (dir) => express.static(dir, {
|
||||
maxAge: '30d',
|
||||
immutable: true,
|
||||
setHeaders: (res, path) => {
|
||||
@@ -242,7 +273,9 @@ app.use('/patch-img', express.static(PATCH_DIR, {
|
||||
// SVG direkt aufgerufen: keine Skripte, keine fremden Inhalte
|
||||
if (path.endsWith('.svg')) res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
|
||||
},
|
||||
}));
|
||||
});
|
||||
app.use('/patch-img', imageStatic(PATCH_DIR));
|
||||
app.use('/koop-img', imageStatic(KOOP_DIR));
|
||||
|
||||
// Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen
|
||||
function todayIso() {
|
||||
@@ -316,12 +349,18 @@ app.get('/api/patches', (req, res) => {
|
||||
res.json(sortedPatches());
|
||||
});
|
||||
|
||||
const sortedCooperations = () => [...db.cooperations].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
|
||||
app.get('/api/kooperationen', (req, res) => {
|
||||
res.json(sortedCooperations());
|
||||
});
|
||||
|
||||
// Ab hier nur mit Login
|
||||
app.use(requireAuth);
|
||||
|
||||
app.post('/api/patches', (req, res) => {
|
||||
app.post('/api/patches', requireAdmin, (req, res) => {
|
||||
const fields = cleanPatch(req.body);
|
||||
const image = savePatchImage(cleanImage(req.body.image));
|
||||
const image = saveImage(PATCH_DIR, cleanImage(req.body.image));
|
||||
const patch = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
|
||||
db.patches.push(patch);
|
||||
saveDb();
|
||||
@@ -329,27 +368,63 @@ app.post('/api/patches', (req, res) => {
|
||||
});
|
||||
|
||||
// Bild ist optional – ohne Bild werden nur Name und Beschreibung geändert
|
||||
app.put('/api/patches/:id', (req, res) => {
|
||||
app.put('/api/patches/:id', requireAdmin, (req, res) => {
|
||||
const patch = findOr404(db.patches, req.params.id, res, 'Patch');
|
||||
if (!patch) return;
|
||||
const fields = cleanPatch(req.body);
|
||||
if (req.body.image) {
|
||||
const old = patch.image;
|
||||
patch.image = savePatchImage(cleanImage(req.body.image));
|
||||
removePatchImage(old);
|
||||
patch.image = saveImage(PATCH_DIR, cleanImage(req.body.image));
|
||||
removeImage(PATCH_DIR, old);
|
||||
}
|
||||
Object.assign(patch, fields);
|
||||
saveDb();
|
||||
res.json(patch);
|
||||
});
|
||||
|
||||
app.delete('/api/patches/:id', (req, res) => {
|
||||
app.delete('/api/patches/:id', requireAdmin, (req, res) => {
|
||||
const patch = db.patches.find((p) => p.id === req.params.id);
|
||||
if (patch) removePatchImage(patch.image);
|
||||
if (patch) removeImage(PATCH_DIR, patch.image);
|
||||
db.patches = db.patches.filter((p) => p.id !== req.params.id);
|
||||
saveDb();
|
||||
res.status(204).end();
|
||||
});
|
||||
// Kooperationen: anlegen, ändern, löschen nur Admin. Titelbild ist optional.
|
||||
app.post('/api/kooperationen', requireAdmin, (req, res) => {
|
||||
const fields = cleanCooperation(req.body);
|
||||
const image = req.body.image ? saveImage(KOOP_DIR, cleanImage(req.body.image)) : null;
|
||||
const coop = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
|
||||
db.cooperations.push(coop);
|
||||
saveDb();
|
||||
res.status(201).json(coop);
|
||||
});
|
||||
|
||||
// image: neue data:-URL ersetzt das Bild, removeImage: true entfernt es, sonst bleibt es
|
||||
app.put('/api/kooperationen/:id', requireAdmin, (req, res) => {
|
||||
const coop = findOr404(db.cooperations, req.params.id, res, 'Kooperation');
|
||||
if (!coop) return;
|
||||
const fields = cleanCooperation(req.body);
|
||||
if (req.body.image) {
|
||||
const old = coop.image;
|
||||
coop.image = saveImage(KOOP_DIR, cleanImage(req.body.image));
|
||||
removeImage(KOOP_DIR, old);
|
||||
} else if (req.body.removeImage) {
|
||||
removeImage(KOOP_DIR, coop.image);
|
||||
coop.image = null;
|
||||
}
|
||||
Object.assign(coop, fields);
|
||||
saveDb();
|
||||
res.json(coop);
|
||||
});
|
||||
|
||||
app.delete('/api/kooperationen/:id', requireAdmin, (req, res) => {
|
||||
const coop = db.cooperations.find((c) => c.id === req.params.id);
|
||||
if (coop) removeImage(KOOP_DIR, coop.image);
|
||||
db.cooperations = db.cooperations.filter((c) => c.id !== req.params.id);
|
||||
saveDb();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.use(versioned(join(ROOT, 'private'), 'private, no-cache'));
|
||||
app.use(express.static(join(ROOT, 'private'), {
|
||||
extensions: ['html'],
|
||||
@@ -361,7 +436,7 @@ app.get('/api/members', (req, res) => {
|
||||
res.json(db.members);
|
||||
});
|
||||
|
||||
app.post('/api/members', (req, res) => {
|
||||
app.post('/api/members', requireAdmin, (req, res) => {
|
||||
const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) };
|
||||
db.members.push(member);
|
||||
saveDb();
|
||||
@@ -376,7 +451,7 @@ app.put('/api/members/:id', (req, res) => {
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
app.delete('/api/members/:id', (req, res) => {
|
||||
app.delete('/api/members/:id', requireAdmin, (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;
|
||||
@@ -386,7 +461,7 @@ app.delete('/api/members/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen)
|
||||
app.put('/api/members/:id/plays-manual', (req, res) => {
|
||||
app.put('/api/members/:id/plays-manual', requireAdmin, (req, res) => {
|
||||
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
|
||||
if (!member) return;
|
||||
const year = new Date().getFullYear();
|
||||
@@ -396,7 +471,7 @@ app.put('/api/members/:id/plays-manual', (req, res) => {
|
||||
});
|
||||
|
||||
// Ersetzt die komplette Gruppe (Import aus JSON-Datei)
|
||||
app.post('/api/members/import', (req, res) => {
|
||||
app.post('/api/members/import', requireAdmin, (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())
|
||||
|
||||
Reference in new issue
Block a user