Files
guyg1474andClaude Opus 5.5 819219fb2d 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>
2026-10-12 19:47:03 +02:00

195 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gemeinsame Helfer für alle Seiten
export const $ = (sel, root = document) => root.querySelector(sel);
export function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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' },
{ 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)
export function renderHeader(active, { overlay = false } = {}) {
const header = document.createElement('header');
header.className = overlay ? 'site-header overlay' : '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);
if (overlay) {
const update = () => header.classList.toggle('scrolled', scrollY > 40);
addEventListener('scroll', update, { passive: true });
update();
}
// 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 = `
<span class="f-brand"><img src="/img/logo.png" alt="" width="32" height="32">HCRB · Highcap Rasselbande · Berlin</span>
<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);
}
// 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, 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) {
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.name} (${user.role === 'admin' ? 'Admin' : 'Mitglied'})`;
btn.addEventListener('click', async () => {
await api('POST', '/logout').catch(() => {});
location.href = '/';
});
nav.append(btn);
}
// Kurzes Zeitformat für Termine: "09:00 Uhr" oder "09:00–15:00 Uhr"
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;
}