mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
- 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>
195 lines
7.4 KiB
JavaScript
195 lines
7.4 KiB
JavaScript
// 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 = `
|
||
<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;
|
||
}
|