Files
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

239 lines
8.6 KiB
JavaScript
Raw Permalink 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 {
$, api, esc, sortByName, showError, renderHeader,
toIso, fromIso, formatDate, formatTime, getMe, setMe,
} from './common.js';
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;
let viewMonth = fromIso(todayIso);
viewMonth.setDate(1);
const memberName = (id) => members.find((m) => m.id === id)?.name ?? '(gelöscht)';
// ---------- "Ich bin" ----------
function renderMe() {
if (me && !members.some((m) => m.id === me)) me = null;
$('#me-select').innerHTML = '<option value="">– bitte auswählen –</option>' +
sortByName(members).map((m) =>
`<option value="${m.id}" ${m.id === me ? 'selected' : ''}>${esc(m.name)}</option>`).join('');
$('#me-hint').hidden = !!me;
$('#event-submit').disabled = !me && !editingId;
}
$('#me-select').addEventListener('change', (e) => {
me = e.target.value || null;
setMe(me);
renderAll();
});
// ---------- Monatsansicht ----------
function renderCalendar() {
const title = viewMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
$('#month-title').textContent = title;
// Raster beginnt am Montag vor (oder am) Monatsersten
const start = new Date(viewMonth);
start.setDate(1 - ((start.getDay() + 6) % 7));
const byDate = new Map();
for (const ev of events) {
if (!byDate.has(ev.date)) byDate.set(ev.date, []);
byDate.get(ev.date).push(ev);
}
let html = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((d) => `<div class="dow">${d}</div>`).join('');
const day = new Date(start);
for (let i = 0; i < 42; i++) {
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.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);
}
$('#calendar').innerHTML = html;
}
$('#calendar').addEventListener('click', (e) => {
const cell = e.target.closest('[data-day]');
if (!cell) return;
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', () => 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 ${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">
${attendees.length
? attendees.map((a) => `<span class="chip ${a.id === me ? 'me' : ''}">${esc(a.name)}</span>`).join('')
: '<span class="muted">noch niemand</span>'}
</div>
<div class="row wrap">
${me ? (going
? `<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>`;
}
function renderDay() {
$('#day-title').textContent = fromIso(selectedDay).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
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 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 Termine.</p>';
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
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(`Termin „${ev.title}“ am ${formatDate(ev.date)} wirklich löschen?`)) return;
await api('DELETE', `/events/${id}`);
if (editingId === id) stopEdit();
}
await load();
} catch (err) {
showError(err);
}
});
// ---------- 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 {
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);
}
});
// ---------- Laden ----------
function renderAll() {
renderMe();
renderCalendar();
renderDay();
renderUpcoming();
}
async function load() {
[members, events] = await Promise.all([api('GET', '/members'), api('GET', '/events')]);
renderAll();
}
$('#event-date').value = selectedDay;
load().catch(showError);