Files
hcrb/public/js/home.js
T
guyg1474andClaude Opus 5.5 486e6f6784 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>
2026-10-12 00:13:52 +02:00

126 lines
4.9 KiB
JavaScript
Raw 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.
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 */ }