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

+27 -16
View File
@@ -7,9 +7,19 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<main>
<header class="page-head no-print">
<div>
<span class="eyebrow">Fahrgemeinschaften</span>
<h1>Fahrtplaner</h1>
<p>Wer fährt, wer steigt wo zu, wann geht es los.</p>
</div>
</header>
<div id="from-event" class="notice no-print" hidden></div>
<div class="split">
<div class="col">
<div class="card no-print">
<div class="card-head">
<h2>Fahrt</h2>
@@ -36,19 +46,6 @@
</label>
</div>
</div>
<div class="card no-print">
<div class="card-head">
<h2>Wer kommt mit?</h2>
<div class="row">
<button type="button" id="select-all" class="small">Alle</button>
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
<div class="card no-print">
<h2>Weitere Personen (nur diese Fahrt)</h2>
<form id="guest-form" class="row wrap">
@@ -60,7 +57,6 @@
</form>
<ul id="guests-list" class="list"></ul>
</div>
<div class="card no-print">
<h2>Feste Zuteilung (nur diese Fahrt)</h2>
<p class="muted">Wer hier eingetragen ist, fährt sicher bei diesem Fahrer mit – der Fahrer fährt dann auf jeden Fall.
@@ -73,8 +69,23 @@
</form>
<ul id="fixed-list" class="list"></ul>
</div>
</div>
<div class="col">
<div class="card no-print">
<div class="card-head">
<h2>Wer kommt mit?</h2>
<div class="row">
<button type="button" id="select-all" class="small">Alle</button>
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
</div>
</div>
<div class="card">
<div class="card" style="margin-top: 20px">
<h2 class="no-print">Einteilung</h2>
<div class="no-print">
<label class="inline">
+23 -8
View File
@@ -7,7 +7,17 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main class="narrow">
<main>
<header class="page-head">
<div>
<span class="eyebrow">Mitglieder &amp; Treffpunkte</span>
<h1>Gruppe</h1>
<p>Wer gehört dazu, wer hat ein Auto und wo trifft man sich.</p>
</div>
</header>
<div class="split wide-right">
<div class="col">
<div class="card">
<h2 id="member-form-title">Mitglied hinzufügen</h2>
<form id="member-form">
@@ -42,13 +52,6 @@
</div>
</form>
</div>
<div class="card">
<h2>Gruppe</h2>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card">
<h2 id="point-form-title">Treffpunkt hinzufügen</h2>
<form id="point-form">
@@ -67,7 +70,14 @@
<p id="points-empty" class="muted" hidden>Noch keine Treffpunkte gespeichert.</p>
<ul id="points-list" class="list"></ul>
</div>
</div>
<div class="col">
<div class="card">
<h2>Gruppe</h2>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card">
<h2>Daten sichern</h2>
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
@@ -78,6 +88,11 @@
</label>
</div>
</div>
</div>
</div>
</main>
<script type="module" src="/js/gruppe.js"></script>
+84 -28
View File
@@ -1,6 +1,6 @@
import {
$, api, esc, sortByName, showError, renderHeader,
toIso, fromIso, formatDate, getMe, setMe,
toIso, fromIso, formatDate, formatTime, getMe, setMe,
} from './common.js';
renderHeader('kalender');
@@ -8,6 +8,7 @@ renderHeader('kalender');
let members = [];
let events = [];
let me = getMe();
let editingId = null; // Termin, der gerade im Formular bearbeitet wird
const todayIso = toIso(new Date());
let selectedDay = todayIso;
@@ -23,7 +24,7 @@ function renderMe() {
sortByName(members).map((m) =>
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
$('#me-hint').hidden = !!me;
$('#event-form button[type=submit]').disabled = !me;
$('#event-submit').disabled = !me && !editingId;
}
$('#me-select').addEventListener('change', (e) => {
@@ -53,14 +54,15 @@ function renderCalendar() {
const iso = toIso(day);
const classes = ['cal-day'];
if (day.getMonth() !== viewMonth.getMonth()) classes.push('other');
if (day.getDay() === 0 || day.getDay() === 6) classes.push('weekend');
if (iso === todayIso) classes.push('today');
if (iso === selectedDay) classes.push('selected');
const dayEvents = byDate.get(iso) || [];
html += `
<button class="${classes.join(' ')}" data-day="${iso}">
<span class="num">${day.getDate()}</span>
${dayEvents.map((ev) => `<span class="ev ${ev.attendees.includes(me) ? 'mine' : ''}"
title="${esc(ev.title)} (${ev.attendees.length} zugesagt)">${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
${dayEvents.map((ev) => `<span class="ev ${ev.isGame ? '' : 'nogame'} ${ev.attendees.includes(me) ? 'mine' : ''}"
title="${esc(ev.title)}${ev.isGame ? '' : ' (kein Spiel)'} · ${ev.attendees.length} zugesagt">${ev.time ? `${ev.time} ` : ''}${esc(ev.title)} · ${ev.attendees.length}</span>`).join('')}
</button>`;
day.setDate(day.getDate() + 1);
}
@@ -70,34 +72,43 @@ function renderCalendar() {
$('#calendar').addEventListener('click', (e) => {
const cell = e.target.closest('[data-day]');
if (!cell) return;
selectedDay = cell.dataset.day;
const d = fromIso(selectedDay);
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
renderAll();
$('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
selectDay(cell.dataset.day);
// Auf schmalen Bildschirmen stehen die Details unter dem Kalender
if (matchMedia('(max-width: 999px)').matches) $('#day-title').scrollIntoView({ behavior: 'smooth', block: 'start' });
});
function selectDay(iso) {
selectedDay = iso;
const d = fromIso(iso);
if (d.getMonth() !== viewMonth.getMonth()) viewMonth = new Date(d.getFullYear(), d.getMonth(), 1);
if (!editingId) $('#event-date').value = iso;
renderAll();
}
function shiftMonth(delta) {
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + delta, 1);
renderCalendar();
}
$('#prev-month').addEventListener('click', () => shiftMonth(-1));
$('#next-month').addEventListener('click', () => shiftMonth(1));
$('#today-btn').addEventListener('click', () => {
selectedDay = todayIso;
viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
renderAll();
});
$('#today-btn').addEventListener('click', () => selectDay(todayIso));
// ---------- Termine ----------
const downloadIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 21h16"/></svg>';
function eventHtml(ev) {
const going = ev.attendees.includes(me);
const attendees = sortByName(ev.attendees.map((id) => ({ id, name: memberName(id) })));
const when = [formatDate(ev.date, true), formatTime(ev)].filter(Boolean).join(' · ');
return `
<div class="event">
<h3>${esc(ev.title)}</h3>
<div class="when">${formatDate(ev.date, true)}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div>
<div class="event ${ev.isGame ? '' : 'nogame'}">
<div class="event-top">
<h3>${esc(ev.title)}</h3>
${ev.isGame
? '<span class="badge game" title="Zählt für die Statistik">Spiel</span>'
: '<span class="badge nogame" title="Zählt nicht für die Statistik">Kein Spiel</span>'}
</div>
<div class="when">${when}${ev.createdBy ? ` · eingetragen von ${esc(memberName(ev.createdBy))}` : ''}</div>
${ev.note ? `<div class="note">${esc(ev.note)}</div>` : ''}
<div class="muted">${ev.attendees.length} zugesagt:</div>
<div class="chips">
@@ -110,6 +121,8 @@ function eventHtml(ev) {
? `<button data-action="leave" data-id="${ev.id}">Absagen</button>`
: `<button class="primary" data-action="join" data-id="${ev.id}">Ich bin dabei</button>`) : ''}
<a class="button" href="/fahrt?termin=${ev.id}">Fahrt planen</a>
<a class="button" href="/api/events/${ev.id}/ics" download title="Termin als .ics-Datei in den eigenen Kalender übernehmen">${downloadIcon}Kalender</a>
<button class="link" data-action="edit" data-id="${ev.id}">Bearbeiten</button>
<button class="link danger" data-action="delete" data-id="${ev.id}">Löschen</button>
</div>
</div>`;
@@ -122,14 +135,14 @@ function renderDay() {
const dayEvents = events.filter((e) => e.date === selectedDay);
$('#day-events').innerHTML = dayEvents.length
? dayEvents.map(eventHtml).join('')
: '<p class="muted">An diesem Tag ist noch kein Spieltag eingetragen.</p>';
: '<p class="muted">An diesem Tag ist noch nichts eingetragen.</p>';
}
function renderUpcoming() {
const upcoming = events.filter((e) => e.date >= todayIso);
$('#upcoming').innerHTML = upcoming.length
? upcoming.map(eventHtml).join('')
: '<p class="muted">Keine kommenden Spieltage.</p>';
: '<p class="muted">Keine kommenden Termine.</p>';
}
document.addEventListener('click', async (e) => {
@@ -137,12 +150,14 @@ document.addEventListener('click', async (e) => {
if (!btn) return;
const { action, id } = btn.dataset;
try {
if (action === 'edit') return startEdit(id);
if (action === 'join') await api('PUT', `/events/${id}/attendees/${me}`);
if (action === 'leave') await api('DELETE', `/events/${id}/attendees/${me}`);
if (action === 'delete') {
const ev = events.find((x) => x.id === id);
if (!confirm(`Spieltag „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
if (!confirm(`Termin „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
await api('DELETE', `/events/${id}`);
if (editingId === id) stopEdit();
}
await load();
} catch (err) {
@@ -150,17 +165,57 @@ document.addEventListener('click', async (e) => {
}
});
// ---------- Formular: eintragen und bearbeiten ----------
function startEdit(id) {
const ev = events.find((x) => x.id === id);
if (!ev) return;
editingId = id;
$('#event-form-title').textContent = 'Termin bearbeiten';
$('#event-submit').textContent = 'Speichern';
$('#event-cancel').hidden = false;
$('#event-title').value = ev.title;
$('#event-game').checked = ev.isGame;
$('#event-date').value = ev.date;
$('#event-time').value = ev.time || '';
$('#event-end').value = ev.endTime || '';
$('#event-note').value = ev.note || '';
renderMe();
$('#event-form').scrollIntoView({ behavior: 'smooth', block: 'center' });
$('#event-title').focus({ preventScroll: true });
}
function stopEdit() {
editingId = null;
$('#event-form').reset();
$('#event-form-title').textContent = 'Termin an diesem Tag eintragen';
$('#event-submit').textContent = 'Eintragen';
$('#event-cancel').hidden = true;
$('#event-date').value = selectedDay;
renderMe();
}
$('#event-cancel').addEventListener('click', stopEdit);
$('#event-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
date: $('#event-date').value,
title: $('#event-title').value,
note: $('#event-note').value,
isGame: $('#event-game').checked,
time: $('#event-time').value,
endTime: $('#event-end').value,
};
try {
await api('POST', '/events', {
date: selectedDay,
title: $('#event-title').value,
note: $('#event-note').value,
createdBy: me,
});
e.target.reset();
if (editingId) {
await api('PUT', `/events/${editingId}`, body);
} else {
await api('POST', '/events', { ...body, createdBy: me });
}
const date = body.date;
stopEdit();
await load();
if (date && date !== selectedDay) selectDay(date);
} catch (err) {
showError(err);
}
@@ -179,4 +234,5 @@ async function load() {
renderAll();
}
$('#event-date').value = selectedDay;
load().catch(showError);
+83 -33
View File
@@ -8,42 +8,92 @@
</head>
<body>
<main>
<div class="card">
<label>Ich bin
<select id="me-select"></select>
</label>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Spieltage eintragen und zusagen kannst.
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
</div>
<div class="card">
<div class="cal-nav">
<button id="prev-month" aria-label="Vorheriger Monat">‹</button>
<h2 id="month-title"></h2>
<button id="next-month" aria-label="Nächster Monat">›</button>
<header class="page-head">
<div>
<span class="eyebrow">Spieltage &amp; Termine</span>
<h1>Kalender</h1>
<p>Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.</p>
</div>
<div class="row end"><button id="today-btn" class="small">Heute</button></div>
<div id="calendar" class="cal-grid"></div>
</div>
<div class="actions">
<a class="button" href="/api/calendar.ics" download title="Alle kommenden Termine als .ics-Datei für Google, Apple oder Outlook">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 21h16"/></svg>
Alle Termine (.ics)
</a>
</div>
</header>
<div class="card">
<h2 id="day-title"></h2>
<div id="day-events"></div>
<form id="event-form">
<h3>Spieltag an diesem Tag eintragen</h3>
<label>Titel / Spielfeld
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Waldfeld">
</label>
<label>Notiz (optional)
<textarea id="event-note" maxlength="500" placeholder="Uhrzeit, Link zum Event, Treffpunkt …"></textarea>
</label>
<button type="submit" class="primary">Eintragen</button>
</form>
</div>
<div class="split wide-left">
<div class="col">
<div class="card">
<div class="cal-nav">
<button id="prev-month" aria-label="Vorheriger Monat">‹</button>
<h2 id="month-title"></h2>
<button id="next-month" aria-label="Nächster Monat">›</button>
<button id="today-btn" class="small">Heute</button>
</div>
<div id="calendar" class="cal-grid"></div>
<div class="cal-legend">
<span><i class="l-game"></i>Spiel-Event</span>
<span><i class="l-nogame"></i>Termin ohne Spiel</span>
<span><i class="l-mine"></i>Du hast zugesagt</span>
</div>
</div>
</div>
<div class="card">
<h2>Kommende Spieltage</h2>
<div id="upcoming"></div>
<div class="col">
<div class="card">
<label>Ich bin
<select id="me-select"></select>
</label>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Termine eintragen und zusagen kannst.
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
</div>
<div class="card">
<h2 id="day-title"></h2>
<div id="day-events"></div>
<form id="event-form">
<h3 id="event-form-title">Termin an diesem Tag eintragen</h3>
<label>Titel / Spielfeld
<input type="text" id="event-title" required maxlength="80" placeholder="z. B. Sonntagsspiel Darkzone">
</label>
<label class="toggle-field">
<input type="checkbox" id="event-game" checked>
<div>
<strong>Spiel-Event</strong>
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen, Grillen oder Basteltage das Häkchen entfernen.</span>
</div>
</label>
<div class="grid2">
<label>Datum
<input type="date" id="event-date" required>
</label>
<div class="grid2 keep">
<label>Beginn
<input type="time" id="event-time">
</label>
<label>Ende
<input type="time" id="event-end">
</label>
</div>
</div>
<label>Notiz (optional)
<textarea id="event-note" maxlength="500" placeholder="Link zum Event, Treffpunkt, Ausrüstung …"></textarea>
</label>
<div class="row wrap">
<button type="submit" class="primary" id="event-submit">Eintragen</button>
<button type="button" id="event-cancel" hidden>Abbrechen</button>
</div>
</form>
</div>
</div>
<section class="span-all">
<div class="section-title">
<h2>Kommende Termine</h2>
</div>
<div id="upcoming" class="events-grid"></div>
</section>
</div>
</main>