mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
- 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>
239 lines
8.6 KiB
JavaScript
239 lines
8.6 KiB
JavaScript
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);
|