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:
guyg1474andClaude Opus 5.5 committed 2026-10-12 19:47:03 +02:00
1 parent f46016487d
commit 819219fb2d
18 files changed
+661 -106

No files matched your search

+37 -3
View File
@@ -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;
}