Spiel-Events, Kalenderdateien, Patches-Seite und neues Design

- Kalender: Termine als Spiel-Event markieren (nur diese zählen in der
  Statistik), optionale Uhrzeiten, Termine bearbeiten
- .ics-Download für einzelne und alle kommenden Termine
- Neue Seite Patches: Upload (PNG, JPEG, WebP, geprüftes SVG),
  Status "Gibt es schon" / "In Planung", Bilder in data/patches/
- Volle Seitenbreite mit Spaltenlayouts, neues Design mit selbst
  gehosteten Schriften, Startseite mit Zoom und drehendem Zahnrad
- Bestehende Daten werden beim Laden ergänzt, keine Migration nötig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-12 00:13:52 +02:00
1 parent 43356a120c
commit 486e6f6784
23 files changed
+1757 -387

No files matched your search

+709 -179
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+102 -88
View File
@@ -4,118 +4,132 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HCRB – Highcap Rasselbande</title>
<meta name="description" content="Highcap Rasselbande (HCRB), Airsoft-Gruppe aus Berlin.">
<meta name="theme-color" content="#121730">
<link rel="preload" href="/fonts/saira-stencil-one-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<section class="hero">
<div>
<h1>HCRB</h1>
<div class="subtitle">Highcap Rasselbande</div>
<body class="home">
<section class="hero" aria-label="HCRB">
<div class="hero-stage" id="hero-stage">
<!-- Flagge in zwei Ebenen: Hintergrund mit Bär steht, das Zahnrad darüber dreht sich beim Scrollen -->
<img class="hero-flag" src="/img/flagge-grund.jpg" alt="Flagge der HCRB: Berliner Bär in einem Zahnrad" width="1600" height="1067" fetchpriority="high">
<img class="hero-flag hero-gear" src="/img/zahnrad.webp" alt="" width="1600" height="1067" fetchpriority="high">
<div class="hero-shade"></div>
<div class="hud" aria-hidden="true">
<span class="tl">52.520° N · 13.405° E</span>
<span class="tr">Airsoft · Berlin</span>
<span class="corner c1"></span><span class="corner c2"></span>
<span class="corner c3"></span><span class="corner c4"></span>
</div>
<div class="hero-title">
<h1>HCRB</h1>
<div class="subtitle">Highcap Rasselbande</div>
</div>
</div>
</section>
<main>
<p class="intro">
Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin.
Am häufigsten findet ihr uns auf der Darkzone.
</p>
<section class="next-op reveal" id="next-op">
<div>
<span class="eyebrow">Nächster Einsatz</span>
<h2 id="next-title">Lädt …</h2>
<div class="when" id="next-when"></div>
</div>
<div class="countdown" id="next-countdown" hidden></div>
</section>
<p class="notice wip">
<strong>Diese Seite ist noch in Arbeit.</strong> Es kommen noch Änderungen und neue Funktionen dazu.
</p>
<div class="tiles">
<a class="tile" href="/kalender">
<nav class="areas reveal" aria-label="Bereiche">
<a class="tile big" href="/kalender">
<svg class="bg-gear" viewBox="0 0 100 100" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M87.8 40.4 L97.5 43.1 L97.5 56.9 L87.8 59.6 L87.5 60.6 L94.6 67.8 L87.7 79.7 L77.9 77.2 L77.2 77.9 L79.7 87.7 L67.8 94.6 L60.6 87.5 L59.6 87.8 L56.9 97.5 L43.1 97.5 L40.4 87.8 L39.4 87.5 L32.2 94.6 L20.3 87.7 L22.8 77.9 L22.1 77.2 L12.3 79.7 L5.4 67.8 L12.5 60.6 L12.2 59.6 L2.5 56.9 L2.5 43.1 L12.2 40.4 L12.5 39.4 L5.4 32.2 L12.3 20.3 L22.1 22.8 L22.8 22.1 L20.3 12.3 L32.2 5.4 L39.4 12.5 L40.4 12.2 L43.1 2.5 L56.9 2.5 L59.6 12.2 L60.6 12.5 L67.8 5.4 L79.7 12.3 L77.2 22.1 L77.9 22.8 L87.7 20.3 L94.6 32.2 L87.5 39.4Z M50 28a22 22 0 1 0 0 44a22 22 0 1 0 0-44Z"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<rect x="3" y="5" width="18" height="16"/><path d="M3 10h18M8 3v4M16 3v4M7 14h3M14 14h3M7 17h3"/>
</svg>
<div>
<h2>Kalender</h2>
<p>Spieltage eintragen und zusagen.</p>
</div>
<h2>Kalender</h2>
<p>Spieltage und Termine eintragen, zusagen und als Kalenderdatei aufs Handy holen.</p>
<span class="go">Zum Kalender</span>
</a>
<a class="tile" href="/fahrt">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<path d="M3 16v-4l2-5h14l2 5v4H3z"/><path d="M3 12h18"/><circle cx="7.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/>
</svg>
<div>
<h2>Fahrtplaner</h2>
<p>Fahrgemeinschaften einteilen.</p>
</div>
<h2>Fahrtplaner</h2>
<p>Fahrgemeinschaften einteilen.</p>
</a>
</div>
<a class="tile" href="/patches">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" 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"/><path d="M8.5 12l2.5 2.5 4.5-5"/>
</svg>
<h2>Patches</h2>
<p>Alle Patches der Bande.</p>
</a>
<a class="tile" href="/statistik">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>
</svg>
<h2>Statistik</h2>
<p>Wer war wie oft spielen?</p>
</a>
<a class="tile" href="/gruppe">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.8c2 .7 3.2 2.4 3.5 5.2"/>
</svg>
<h2>Gruppe</h2>
<p>Mitglieder und Treffpunkte.</p>
</a>
</nav>
<div class="card">
<div class="card-head">
<h2>Nächste Spieltage</h2>
<a href="/kalender" class="button small">Zum Kalender</a>
<section id="patch-section" class="reveal" hidden>
<div class="section-title">
<h2>Unsere Patches</h2>
<a href="/patches" class="button small">Alle Patches</a>
</div>
<div id="upcoming"><p class="muted">Lädt …</p></div>
</div>
<div class="patch-rail" id="patch-rail"></div>
</section>
<div class="card">
<div class="card-head">
<h2>Pile of Shame</h2>
<a href="/statistik" class="button small">Zur Statistik</a>
<div class="home-grid reveal">
<div class="card">
<div class="card-head">
<h2>Nächste Termine</h2>
<a href="/kalender" class="button small">Zum Kalender</a>
</div>
<div id="upcoming"><p class="muted">Lädt …</p></div>
</div>
<div id="shame"><p class="muted">Lädt …</p></div>
</div>
<div class="card">
<h2>Über uns</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
gibt es auf YouTube und Instagram:
</p>
<div class="social">
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/></svg>YouTube
</a>
<a class="button" href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.6" cy="6.4" r="1" fill="currentColor" stroke="none"/></svg>Instagram
</a>
<div class="card">
<div class="card-head">
<h2>Pile of Shame</h2>
<a href="/statistik" class="button small">Zur Statistik</a>
</div>
<div id="shame"><p class="muted">Lädt …</p></div>
</div>
</div>
<section class="about reveal">
<img src="/img/logo.png" alt="" width="128" height="128">
<div>
<span class="eyebrow">Über uns</span>
<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
gibt es auf YouTube und Instagram.
</p>
<div class="social">
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/></svg>YouTube
</a>
<a class="button" href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.6" cy="6.4" r="1" fill="currentColor" stroke="none"/></svg>Instagram
</a>
</div>
</div>
</section>
</main>
<script type="module">
import { renderHeader, renderFooter, api, esc, formatDate } from '/js/common.js';
renderHeader('home');
renderFooter();
const box = document.getElementById('upcoming');
try {
const events = await api('GET', '/upcoming');
box.innerHTML = events.length
? `<ul class="list">${events.map((e) => `
<li>
<span class="name"><strong>${esc(e.title)}</strong></span>
<span class="meta">${formatDate(e.date, true)}</span>
<span class="badge">${e.attendeeCount} zugesagt</span>
</li>`).join('')}</ul>`
: '<p class="muted">Noch keine Spieltage eingetragen.</p>';
} catch {
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
}
// Pile of Shame: wer dieses Jahr noch nicht spielen war
const shame = document.getElementById('shame');
try {
const { year, entries } = await api('GET', '/stats');
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
shame.innerHTML = names.length
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
: `<p class="muted">Leer – alle waren ${year} schon spielen.</p>`;
} catch {
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
}
</script>
<script type="module" src="/js/home.js"></script>
</body>
</html>
+24 -4
View File
@@ -80,11 +80,13 @@ const NAV = [
{ href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' },
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
{ href: '/statistik', key: 'statistik', label: 'Statistik' },
{ href: '/patches', key: 'patches', label: 'Patches' },
];
export function renderHeader(active) {
// 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 = 'site-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>
@@ -98,6 +100,11 @@ export function renderHeader(active) {
</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', () => {
@@ -114,7 +121,7 @@ export function renderHeader(active) {
export function renderFooter() {
const footer = document.createElement('footer');
footer.innerHTML = `
HCRB · Highcap Rasselbande · Berlin
<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>
@@ -122,8 +129,15 @@ export function renderFooter() {
document.body.append(footer);
}
// Einmal pro Seite abfragen, ob jemand angemeldet ist
let mePromise;
export function currentUser() {
mePromise ??= api('GET', '/me').then(({ user }) => user || null).catch(() => null);
return mePromise;
}
async function renderAuthLink(nav) {
const { user } = await api('GET', '/me').catch(() => ({}));
const user = await currentUser();
if (!user) {
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
return;
@@ -138,3 +152,9 @@ async function renderAuthLink(nav) {
});
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`;
}
+125
View File
@@ -0,0 +1,125 @@
import { renderHeader, renderFooter, api, esc, formatDate, fromIso, toIso, formatTime } from './common.js';
renderHeader('home', { overlay: true });
renderFooter();
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
// ---------- Scrollen: Flagge zoomt heran ----------
// Eine einzige rAF-Schleife, die nur läuft, wenn gescrollt wurde.
const stage = document.getElementById('hero-stage');
const hero = stage.parentElement;
let ticking = false;
function frame() {
ticking = false;
if (reduceMotion.matches) {
stage.style.setProperty('--p', 0);
return;
}
const range = Math.max(1, hero.offsetHeight - innerHeight);
const p = Math.min(1, Math.max(0, scrollY / range));
stage.style.setProperty('--p', p.toFixed(4));
}
function requestFrame() {
if (!ticking) {
ticking = true;
requestAnimationFrame(frame);
}
}
addEventListener('scroll', requestFrame, { passive: true });
addEventListener('resize', requestFrame);
reduceMotion.addEventListener('change', requestFrame);
frame();
// ---------- Abschnitte gleiten beim ersten Sichtbarwerden herein ----------
// Nur was beim Laden unterhalb des Bildschirms liegt, wird erst versteckt – sichtbarer Inhalt bleibt sichtbar.
if (!reduceMotion.matches && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('reveal-in');
entry.target.classList.remove('reveal-pending');
observer.unobserve(entry.target);
}
}, { rootMargin: '0px 0px -10% 0px' });
for (const el of document.querySelectorAll('.reveal')) {
if (el.getBoundingClientRect().top > innerHeight) {
el.classList.add('reveal-pending');
observer.observe(el);
}
}
}
// ---------- Nächster Einsatz + nächste Termine ----------
function daysUntil(iso) {
return Math.round((fromIso(iso) - fromIso(toIso(new Date()))) / 86400000);
}
function renderNext(events) {
const next = events.find((e) => e.isGame) || events[0];
const title = document.getElementById('next-title');
const when = document.getElementById('next-when');
const box = document.getElementById('next-countdown');
if (!next) {
title.textContent = 'Noch nichts geplant';
when.textContent = 'Der nächste Spieltag steht noch nicht im Kalender.';
return;
}
title.textContent = next.title;
when.textContent = [formatDate(next.date, true), formatTime(next)].filter(Boolean).join(' · ');
const days = daysUntil(next.date);
const label = next.attendeeCount === 1 ? '1 Zusage' : `${next.attendeeCount} Zusagen`;
box.innerHTML = days <= 0
? `<span class="big">Heute</span><span class="unit">${label}</span>`
: `<span class="big">${days}</span>
<span class="unit">${days === 1 ? 'Tag' : 'Tage'}<small>bis zum Spiel · ${label}</small></span>`;
box.hidden = false;
}
const box = document.getElementById('upcoming');
try {
const events = await api('GET', '/upcoming');
renderNext(events);
box.innerHTML = events.length
? `<ul class="list">${events.map((e) => `
<li>
<span class="name"><strong>${esc(e.title)}</strong>
<span class="meta">· ${formatDate(e.date, true)}${e.time ? `, ${e.time} Uhr` : ''}</span></span>
${e.isGame ? '' : '<span class="badge nogame">Kein Spiel</span>'}
<span class="badge">${e.attendeeCount} zugesagt</span>
</li>`).join('')}</ul>`
: '<p class="muted">Noch keine Termine eingetragen.</p>';
} catch {
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
document.getElementById('next-title').textContent = 'Termine nicht erreichbar';
}
// ---------- Pile of Shame: wer dieses Jahr noch nicht spielen war ----------
const shame = document.getElementById('shame');
try {
const { year, entries } = await api('GET', '/stats');
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
shame.innerHTML = names.length
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
: `<p class="muted">Leer, alle waren ${year} schon spielen.</p>`;
} catch {
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
}
// ---------- Patches (neueste zuerst) ----------
try {
const patches = await api('GET', '/patches');
if (patches.length) {
document.getElementById('patch-rail').innerHTML = patches.slice(0, 12).map((p) => `
<a href="/patches#${p.id}">
<figure>
<div class="patch-img"><img src="/patch-img/${encodeURIComponent(p.image)}" alt="" loading="lazy"></div>
<figcaption>${esc(p.name)}${p.status === 'geplant' ? ' <span class="badge nogame">In Planung</span>' : ''}</figcaption>
</figure>
</a>`).join('');
document.getElementById('patch-section').hidden = false;
}
} catch { /* Abschnitt bleibt ausgeblendet */ }
+202
View File
@@ -0,0 +1,202 @@
import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js';
renderHeader('patches');
renderFooter();
let patches = [];
let loggedIn = false;
let editingId = null;
let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes
const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`;
// ---------- Galerie ----------
function render() {
$('#patch-empty').hidden = patches.length > 0;
$('#patch-empty-hint').textContent = loggedIn
? 'Lade über „Patch hochladen“ den ersten Patch hoch.'
: 'Sobald jemand aus der Gruppe 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;
$(`#count-${status}`).textContent = list.length === 1 ? '1 Patch' : `${list.length} Patches`;
$(`#grid-${status}`).innerHTML = list.map(patchHtml).join('');
}
}
function patchHtml(p) {
return `
<article class="patch ${p.status === 'geplant' ? 'planned' : ''}" id="${p.id}">
<button type="button" class="patch-img" data-zoom="${p.id}" aria-label="${esc(p.name)} groß anzeigen">
<img src="${imgUrl(p)}" alt="${esc(p.name)}" loading="lazy">
</button>
<div class="patch-body">
${p.status === 'geplant' ? '<span class="badge nogame">In Planung</span>' : ''}
<h3>${esc(p.name)}</h3>
${p.description ? `<p>${esc(p.description)}</p>` : ''}
${loggedIn ? `
<div class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-delete="${p.id}">Löschen</button>
</div>` : ''}
</div>
</article>`;
}
document.addEventListener('click', async (e) => {
if (!e.target.closest('.patch-grid')) return;
const zoom = e.target.closest('[data-zoom]');
if (zoom) return openLightbox(zoom.dataset.zoom);
const edit = e.target.closest('[data-edit]');
if (edit) return openForm(patches.find((p) => p.id === edit.dataset.edit));
const del = e.target.closest('[data-delete]');
if (del) {
const p = patches.find((x) => x.id === del.dataset.delete);
if (!confirm(`Patch „${p.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/patches/${p.id}`);
await load();
} catch (err) {
showError(err);
}
}
});
function openLightbox(id) {
const p = patches.find((x) => x.id === id);
if (!p) return;
$('#lightbox-img').src = imgUrl(p);
$('#lightbox-img').alt = p.name;
$('#lightbox-name').textContent = p.name;
$('#lightbox-text').textContent = p.description;
$('#lightbox').showModal();
}
// Klick neben das Bild schließt
$('#lightbox').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.close();
});
// ---------- Formular ----------
function openForm(patch) {
editingId = patch?.id ?? null;
imageData = null;
$('#patch-form').reset();
$('#patch-error').hidden = true;
$('#patch-form-title').textContent = patch ? 'Patch bearbeiten' : 'Patch hochladen';
$('#patch-submit').textContent = patch ? 'Speichern' : 'Hochladen';
$('#patch-name').value = patch?.name ?? '';
$('#patch-description').value = patch?.description ?? '';
$(`#patch-status-${patch?.status ?? 'vorhanden'}`).checked = true;
showPreview(patch ? imgUrl(patch) : null);
$('#patch-form-card').hidden = false;
$('#patch-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('#patch-name').focus({ preventScroll: true });
}
function closeForm() {
$('#patch-form-card').hidden = true;
editingId = null;
imageData = null;
}
function showPreview(src) {
$('#patch-preview').hidden = !src;
if (src) $('#patch-preview').src = src;
$('#drop-text').innerHTML = src
? '<strong>Anderes Bild wählen</strong>'
: '<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;
$('#patch-error').hidden = true;
try {
imageData = await prepareImage(file);
showPreview(imageData);
if (!$('#patch-name').value) $('#patch-name').value = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 80);
} catch (err) {
imageData = null;
$('#patch-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.';
$('#patch-error').hidden = false;
}
}
$('#patch-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));
$('#patch-cancel').addEventListener('click', closeForm);
$('#patch-form').addEventListener('submit', async (e) => {
e.preventDefault();
const error = $('#patch-error');
error.hidden = true;
if (!editingId && !imageData) {
error.textContent = 'Bitte zuerst ein Bild auswählen.';
error.hidden = false;
return;
}
const button = $('#patch-submit');
button.disabled = true;
try {
const body = {
name: $('#patch-name').value,
description: $('#patch-description').value,
status: document.querySelector('input[name="patch-status"]:checked').value,
};
if (imageData) body.image = imageData;
if (editingId) await api('PUT', `/patches/${editingId}`, body);
else await api('POST', '/patches', body);
closeForm();
await load();
} catch (err) {
error.textContent = err.message;
error.hidden = false;
} finally {
button.disabled = false;
}
});
// ---------- Laden ----------
async function load() {
patches = await api('GET', '/patches');
render();
}
loggedIn = !!(await currentUser());
$('#add-btn').hidden = !loggedIn;
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' });
+8 -6
View File
@@ -17,11 +17,14 @@ function render() {
$('#stats-edit-hint').hidden = !canEdit;
// Gleiche Anzahl → gleicher Platz
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
const max = Math.max(1, ...stats.map((s) => s.total));
$('#stats-list').innerHTML = stats.map((s) => `
<li>
<li class="${rank(s) <= 3 && s.total > 0 ? 'top' : ''}">
<span class="rank">${rank(s)}.</span>
<span class="name">${esc(s.name)}</span>
${detail(s)}
<span class="who">
<span class="name">${esc(s.name)} ${detail(s)}</span>
<span class="stat-bar" aria-hidden="true"><i style="width: ${(s.total / max) * 100}%"></i></span>
</span>
<span class="actions">
${canEdit ? `
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
@@ -33,7 +36,7 @@ function render() {
}
$('#stats-list').addEventListener('click', async (e) => {
const { step, id } = e.target.dataset;
const { step, id } = e.target.closest('[data-step]')?.dataset ?? {};
if (!step) return;
const s = stats.find((x) => x.id === id);
try {
@@ -56,8 +59,7 @@ async function load(year) {
$('#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
? `Gezählt werden die Spieltage seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat. `
+ 'Zu Neujahr wird das Jahr gespeichert und die Zählung beginnt von vorn. Externe werden nicht aufgeführt.'
? `Laufendes Jahr: Spiel-Events seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat.`
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
render();
}
+5 -1
View File
@@ -9,7 +9,11 @@
<body>
<main class="login">
<div class="card">
<h2>Anmelden</h2>
<div class="card-head">
<h2>Anmelden</h2>
<img src="/img/logo.png" alt="" width="48" height="48">
</div>
<p class="muted">Kalender, Fahrtplaner und Gruppe sind nur für Mitglieder.</p>
<form id="login-form">
<label>Benutzername
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Patches – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main>
<header class="page-head">
<div>
<span class="eyebrow">Klett &amp; Stoff</span>
<h1>Patches</h1>
<p>Gruppen-Patches, Event-Patches und alles, was bei der HCRB auf die Weste kommt.</p>
</div>
<div class="actions">
<button type="button" id="add-btn" class="primary" hidden>Patch hochladen</button>
</div>
</header>
<div class="card" id="patch-form-card" hidden>
<h2 id="patch-form-title">Patch hochladen</h2>
<form id="patch-form" class="split">
<label class="dropzone" id="dropzone" for="patch-file">
<img id="patch-preview" alt="Vorschau" hidden>
<span id="drop-text">
<strong>Bild auswählen</strong><br>
oder hierher ziehen · PNG, JPEG, WebP oder SVG
</span>
<input type="file" id="patch-file" accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg" hidden>
</label>
<div>
<label>Name
<input type="text" id="patch-name" required maxlength="80" placeholder="z. B. HCRB Gruppenpatch 2026">
</label>
<fieldset class="segmented">
<legend>Status</legend>
<label><input type="radio" name="patch-status" id="patch-status-vorhanden" value="vorhanden" checked> Gibt es schon</label>
<label><input type="radio" name="patch-status" id="patch-status-geplant" value="geplant"> In Planung</label>
</fieldset>
<label>Beschreibung (optional)
<textarea id="patch-description" maxlength="500" placeholder="Wofür, seit wann, wer hat ihn entworfen …"></textarea>
</label>
<p class="muted">Tipp: Ein freigestelltes PNG oder eine SVG ohne Hintergrund sieht in der Galerie am besten aus.
Große Bilder werden vor dem Hochladen automatisch verkleinert.</p>
<p id="patch-error" class="warn" hidden></p>
<div class="row wrap">
<button type="submit" class="primary" id="patch-submit">Hochladen</button>
<button type="button" id="patch-cancel">Abbrechen</button>
</div>
</div>
</form>
</div>
<section class="patch-group" id="group-vorhanden" hidden>
<div class="section-title"><h2>Gibt es schon</h2><span class="muted" id="count-vorhanden"></span></div>
<div class="patch-grid" id="grid-vorhanden"></div>
</section>
<section class="patch-group" id="group-geplant" hidden>
<div class="section-title"><h2>In Planung</h2><span class="muted" id="count-geplant"></span></div>
<div class="patch-grid" id="grid-geplant"></div>
</section>
<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>
</div>
<dialog class="lightbox" id="lightbox">
<form method="dialog"><button class="close small" aria-label="Schließen">✕</button></form>
<figure>
<img id="lightbox-img" alt="">
<figcaption><strong id="lightbox-name"></strong><span id="lightbox-text" class="muted"></span></figcaption>
</figure>
</dialog>
</main>
<script type="module" src="/js/patches.js"></script>
</body>
</html>
+28 -9
View File
@@ -7,17 +7,36 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<div class="card">
<div class="card-head">
<h2>Gespielte Spieltage</h2>
<main>
<header class="page-head">
<div>
<span class="eyebrow">Gespielte Spieltage</span>
<h1>Statistik</h1>
<p id="stats-info"></p>
</div>
<div class="actions">
<select id="stats-year" aria-label="Jahr"></select>
</div>
<p id="stats-info" class="muted"></p>
<p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten,
die nicht im Kalender standen.</p>
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
<ol id="stats-list" class="list stats"></ol>
</header>
<div class="split wide-left">
<div class="card">
<h2>Rangliste</h2>
<p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten,
die nicht im Kalender standen.</p>
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
<ol id="stats-list" class="list stats"></ol>
</div>
<div class="card">
<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>Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.</li>
</ul>
</div>
</div>
</main>