mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-16 07:13:28 +02:00
Erste Version: HCRB Spieltag-Kalender und Fahrtplaner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
10400eee59
30 files changed
+3771
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// 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' },
|
||||
];
|
||||
|
||||
export function renderHeader(active) {
|
||||
const header = document.createElement('header');
|
||||
header.className = '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);
|
||||
// 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 = `
|
||||
HCRB · Highcap Rasselbande · Berlin
|
||||
<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);
|
||||
}
|
||||
|
||||
async function renderAuthLink(nav) {
|
||||
const { user } = await api('GET', '/me').catch(() => ({}));
|
||||
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}`;
|
||||
btn.addEventListener('click', async () => {
|
||||
await api('POST', '/logout').catch(() => {});
|
||||
location.href = '/';
|
||||
});
|
||||
nav.append(btn);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { $, api, renderHeader, renderFooter } from './common.js';
|
||||
|
||||
renderHeader('login');
|
||||
renderFooter();
|
||||
|
||||
// Nur Pfade auf dieser Seite zulassen (kein Weiterleiten auf fremde Seiten)
|
||||
function nextUrl() {
|
||||
const next = new URLSearchParams(location.search).get('next') || '';
|
||||
return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/kalender';
|
||||
}
|
||||
|
||||
const { user } = await api('GET', '/me');
|
||||
if (user) location.replace(nextUrl());
|
||||
|
||||
$('#login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const error = $('#login-error');
|
||||
const button = e.target.querySelector('button');
|
||||
error.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('POST', '/login', { username: $('#login-user').value, password: $('#login-password').value });
|
||||
location.replace(nextUrl());
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
$('#login-password').value = '';
|
||||
$('#login-password').focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#login-user').focus();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
|
||||
|
||||
renderHeader('statistik');
|
||||
renderFooter();
|
||||
|
||||
let stats = [];
|
||||
let canEdit = false; // angemeldet und laufendes Jahr
|
||||
|
||||
function detail(s) {
|
||||
if (!s.manual) return '';
|
||||
const sign = s.manual > 0 ? '+' : '−';
|
||||
return `<span class="meta">${s.calendar} aus Kalender ${sign} ${Math.abs(s.manual)} manuell</span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
$('#stats-empty').hidden = stats.length > 0;
|
||||
$('#stats-edit-hint').hidden = !canEdit;
|
||||
// Gleiche Anzahl → gleicher Platz
|
||||
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
|
||||
$('#stats-list').innerHTML = stats.map((s) => `
|
||||
<li>
|
||||
<span class="rank">${rank(s)}.</span>
|
||||
<span class="name">${esc(s.name)}</span>
|
||||
${detail(s)}
|
||||
<span class="actions">
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
|
||||
<span class="stat-total">${s.total}</span>
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="1" data-id="${s.id}" aria-label="Ein Spiel mehr">+</button>` : ''}
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#stats-list').addEventListener('click', async (e) => {
|
||||
const { step, id } = e.target.dataset;
|
||||
if (!step) return;
|
||||
const s = stats.find((x) => x.id === id);
|
||||
try {
|
||||
const { manual } = await api('PUT', `/members/${id}/plays-manual`, { manual: Math.max(s.manual, -s.calendar) + Number(step) });
|
||||
s.manual = manual;
|
||||
s.total = Math.max(0, s.calendar + manual);
|
||||
stats.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
|
||||
render();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
async function load(year) {
|
||||
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
|
||||
stats = data.entries;
|
||||
canEdit = loggedIn && data.live;
|
||||
$('#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.'
|
||||
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
|
||||
render();
|
||||
}
|
||||
|
||||
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
|
||||
|
||||
api('GET', '/me')
|
||||
.then(({ user }) => { loggedIn = !!user; return load(); })
|
||||
.catch(showError);
|
||||
Reference in new issue
Block a user