// 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' }, { 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 = `
HCRBHighcap Rasselbande
`; 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', ''); } if (active !== 'login') renderAuthLink(header.querySelector('nav')); } // ---------- Fußzeile (öffentliche Seiten) ---------- export function renderFooter() { const footer = document.createElement('footer'); footer.innerHTML = ` HCRB · Highcap Rasselbande · Berlin `; document.body.append(footer); } // 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', 'Anmelden'); 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; }