mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-16 07:13:28 +02:00
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:
1 parent
43356a120c
commit
486e6f6784
23 files changed
+1757
-387
No files matched your search
+24
-4
@@ -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`;
|
||||
}
|
||||
@@ -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 */ }
|
||||
@@ -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' });
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user