mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23: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
+27
-16
@@ -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
@@ -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 & 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
@@ -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
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user