Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98c3207c00 | |||
| cb70a095b2 |
5 changed files with 451 additions and 440 deletions
|
|
@ -107,6 +107,10 @@
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
.event-description {
|
||||||
|
/* Descriptions carry their own line breaks, keep them. */
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
.no-events {
|
.no-events {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
|
||||||
36
assets/css/upcoming.css
Normal file
36
assets/css/upcoming.css
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* The rows of the table are added by JavaScript and end up as direct children
|
||||||
|
of the table, so the table styling of the theme, which addresses tbody, does
|
||||||
|
not reach them. Separate the date from the name of the event ourselves, and
|
||||||
|
keep the date on one line, it is one piece of information and reads badly
|
||||||
|
broken after the weekday. */
|
||||||
|
#upcoming td:first-child {
|
||||||
|
padding-inline-end: 1em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The table stands in a prose column, which the theme limits to 65 characters
|
||||||
|
so that running text stays readable. A date and the name of an event next to
|
||||||
|
each other do not fit into that, so the names were wrapped over several
|
||||||
|
lines. Lift the limit off the column and put it back on everything in it
|
||||||
|
except the table, which leaves the table room to grow while the text around
|
||||||
|
it keeps its width. */
|
||||||
|
section.prose:has(> #upcoming) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.prose:has(> #upcoming) > :not(#upcoming) {
|
||||||
|
max-width: 65ch;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The theme lays a table out as a block, "table { display: block; overflow:
|
||||||
|
auto }", so that a wide one can be scrolled sideways. A block fills its
|
||||||
|
parent instead of shrinking to its content the way a table does, so across
|
||||||
|
the whole width of the page the entries would stay at its left edge.
|
||||||
|
fit-content asks for the width of the content, which the automatic margins
|
||||||
|
then centre, and it never exceeds the column, so a display too narrow for
|
||||||
|
the table still wraps the names instead of overflowing. */
|
||||||
|
#upcoming {
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
@ -1,449 +1,418 @@
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
import ICAL from "./vendor/ical.js";
|
||||||
(function(){
|
|
||||||
let events = [];
|
|
||||||
let eventsByDate = {};
|
|
||||||
|
|
||||||
// Funktion zum Parsen der ICS-Datei
|
const icsUrl = "/calendars/all.ics";
|
||||||
function parseICS(icsText) {
|
|
||||||
let events = [];
|
|
||||||
let lines = icsText.split(/\r?\n/);
|
|
||||||
let event = null;
|
|
||||||
lines.forEach(line => {
|
|
||||||
if (line.startsWith("BEGIN:VEVENT")) {
|
|
||||||
event = {};
|
|
||||||
} else if (line.startsWith("END:VEVENT")) {
|
|
||||||
if (event) events.push(event);
|
|
||||||
event = null;
|
|
||||||
} else if (event) {
|
|
||||||
let colonIndex = line.indexOf(":");
|
|
||||||
if (colonIndex > -1) {
|
|
||||||
let key = line.substring(0, colonIndex);
|
|
||||||
let value = line.substring(colonIndex + 1);
|
|
||||||
|
|
||||||
// Handle properties with parameters (like TZID)
|
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
||||||
const baseKey = key.split(";")[0];
|
// no matter which time zone the browser of the visitor is set to.
|
||||||
|
const timeZone = "Europe/Berlin";
|
||||||
|
|
||||||
if (baseKey === "DTSTART") {
|
const monthNames = [
|
||||||
event.start = value;
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
} else if (baseKey === "DTEND") {
|
];
|
||||||
event.end = value;
|
|
||||||
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
|
|
||||||
} else if (baseKey === "SUMMARY") {
|
|
||||||
event.summary = value;
|
|
||||||
} else if (baseKey === "DESCRIPTION") {
|
|
||||||
event.description = value;
|
|
||||||
} else if (baseKey === "RRULE") {
|
|
||||||
event.rrule = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
function parseDateString(icsDateStr) {
|
timeZone,
|
||||||
// Handle different date formats
|
year: "numeric",
|
||||||
if (!icsDateStr) return null;
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
// For basic date format: YYYYMMDD
|
});
|
||||||
if (icsDateStr.length === 8) {
|
|
||||||
let year = icsDateStr.substring(0, 4);
|
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
let month = icsDateStr.substring(4, 6);
|
timeZone,
|
||||||
let day = icsDateStr.substring(6, 8);
|
hour: "2-digit",
|
||||||
return `${year}-${month}-${day}`;
|
minute: "2-digit",
|
||||||
}
|
});
|
||||||
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
|
|
||||||
else if (icsDateStr.includes("T")) {
|
let calendar = null;
|
||||||
let year = icsDateStr.substring(0, 4);
|
let eventsByDate = {};
|
||||||
let month = icsDateStr.substring(4, 6);
|
let currentYear;
|
||||||
let day = icsDateStr.substring(6, 8);
|
let currentMonth;
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
let currentMonthElem;
|
||||||
return null;
|
let calendarBody;
|
||||||
|
let eventPanel;
|
||||||
|
let eventDateElem;
|
||||||
|
let eventDetailsElem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day a point in time falls on in Berlin.
|
||||||
|
*
|
||||||
|
* @param {Date} date The point in time
|
||||||
|
* @returns {string} The day as "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function dayKey(date) {
|
||||||
|
const parts = {};
|
||||||
|
|
||||||
|
for (const part of dayKeyFormat.formatToParts(date)) {
|
||||||
|
parts[part.type] = part.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The days an event covers, so that an event running over several days is
|
||||||
|
* shown on each of them and not only on the day it starts.
|
||||||
|
*
|
||||||
|
* @param {Date} start Start of the event
|
||||||
|
* @param {Date} end End of the event
|
||||||
|
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function daysCovered(start, end) {
|
||||||
|
// The end is not part of the event: one ending at midnight belongs to the day
|
||||||
|
// before, and an all day event ends on the day before its DTEND.
|
||||||
|
const lastInstant = new Date(Math.max(start.getTime(), end.getTime() - 1));
|
||||||
|
const lastKey = dayKey(lastInstant);
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
let key = dayKey(start);
|
||||||
|
|
||||||
|
// The guard keeps a broken event from looping forever, a year of dots on the
|
||||||
|
// same event is well past the point where the calendar is still useful.
|
||||||
|
while (days.length <= 366) {
|
||||||
|
days.push(key);
|
||||||
|
|
||||||
|
if (key === lastKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step to noon UTC of the next day, which is inside the same Berlin day
|
||||||
|
// whether daylight saving time is in effect or not.
|
||||||
|
const [year, month, day] = key.split("-").map(Number);
|
||||||
|
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the URL of an event.
|
||||||
|
*
|
||||||
|
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event to read the URL of
|
||||||
|
* @returns {string} The URL, empty when the event has none
|
||||||
|
*/
|
||||||
|
function eventUrl(event) {
|
||||||
|
return event.component.getFirstPropertyValue("url") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce one occurrence of an event to what the calendar displays.
|
||||||
|
*
|
||||||
|
* @param {ICAL.Event} event The event the occurrence belongs to
|
||||||
|
* @param {ICAL.Time} startDate Start of this occurrence
|
||||||
|
* @param {ICAL.Time} endDate End of this occurrence
|
||||||
|
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean}}
|
||||||
|
*/
|
||||||
|
function toOccurrence(event, startDate, endDate) {
|
||||||
|
return {
|
||||||
|
summary: event.summary ?? "",
|
||||||
|
description: event.description ?? "",
|
||||||
|
url: eventUrl(event),
|
||||||
|
start: startDate.toJSDate(),
|
||||||
|
end: endDate.toJSDate(),
|
||||||
|
allDay: startDate.isDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect everything that takes place in the given month, keyed by day.
|
||||||
|
*
|
||||||
|
* @param {number} year The year of the month
|
||||||
|
* @param {number} month The month, January is 0
|
||||||
|
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
|
||||||
|
*/
|
||||||
|
function occurrencesOfMonth(year, month) {
|
||||||
|
const byDate = {};
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return byDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These bounds only limit how far the recurrences have to be expanded, which
|
||||||
|
// day an occurrence ends up on is decided by its Berlin day below. They are
|
||||||
|
// deliberately generous so that no occurrence is cut off at the edge of the
|
||||||
|
// month by a time zone difference.
|
||||||
|
const from = new Date(year, month, 1);
|
||||||
|
const to = new Date(year, month + 1, 1);
|
||||||
|
from.setDate(from.getDate() - 2);
|
||||||
|
to.setDate(to.getDate() + 2);
|
||||||
|
|
||||||
|
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
|
||||||
|
|
||||||
|
const add = (occurrence) => {
|
||||||
|
for (const key of daysCovered(occurrence.start, occurrence.end)) {
|
||||||
|
if (!key.startsWith(monthPrefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byDate[key]) {
|
||||||
|
byDate[key] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
byDate[key].push(occurrence);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const component of calendar.getAllSubcomponents("vevent")) {
|
||||||
|
const event = new ICAL.Event(component);
|
||||||
|
|
||||||
|
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||||
|
// belong to, handling them here as well would show them twice.
|
||||||
|
if (event.isRecurrenceException() || !event.startDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.isRecurring()) {
|
||||||
|
const iterator = event.iterator();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const occurrence = iterator.next();
|
||||||
|
|
||||||
|
// Recurrences are chronological, so we are done once one starts after
|
||||||
|
// the month.
|
||||||
|
if (!occurrence || occurrence.toJSDate() >= to) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract date components from different date formats
|
// Details resolve time, name and URL of an occurrence that was
|
||||||
function getDateComponents(icsDateStr) {
|
// modified via RECURRENCE-ID.
|
||||||
if (!icsDateStr) return null;
|
const details = event.getOccurrenceDetails(occurrence);
|
||||||
|
|
||||||
// Basic handling - extract YYYY, MM, DD regardless of format
|
if (details.endDate.toJSDate() > from) {
|
||||||
const year = parseInt(icsDateStr.substring(0, 4));
|
add(toOccurrence(details.item, details.startDate, details.endDate));
|
||||||
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
|
|
||||||
const day = parseInt(icsDateStr.substring(6, 8));
|
|
||||||
|
|
||||||
return { year, month, day };
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandRecurringEvents(event, year, month) {
|
|
||||||
if (!event.rrule) return [event];
|
|
||||||
|
|
||||||
const rruleStr = event.rrule;
|
|
||||||
|
|
||||||
// Get start date components
|
|
||||||
const startComponents = getDateComponents(event.start);
|
|
||||||
if (!startComponents) return [event];
|
|
||||||
|
|
||||||
const startDate = new Date(
|
|
||||||
startComponents.year,
|
|
||||||
startComponents.month,
|
|
||||||
startComponents.day
|
|
||||||
);
|
|
||||||
|
|
||||||
const rangeStart = new Date(year, month, 1);
|
|
||||||
const rangeEnd = new Date(year, month + 1, 0);
|
|
||||||
const expandedEvents = [];
|
|
||||||
|
|
||||||
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
|
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
|
||||||
if (bydayMatch) {
|
|
||||||
const dayCode = bydayMatch[1];
|
|
||||||
const dayMap = {
|
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
|
||||||
};
|
|
||||||
const targetDay = dayMap[dayCode];
|
|
||||||
|
|
||||||
if (targetDay !== undefined) {
|
|
||||||
// Create events for each matching day in the month
|
|
||||||
let day = 1;
|
|
||||||
while (day <= rangeEnd.getDate()) {
|
|
||||||
const testDate = new Date(year, month, day);
|
|
||||||
if (testDate.getDay() === targetDay && testDate >= startDate) {
|
|
||||||
const newEvent = {...event};
|
|
||||||
const eventDate = formatDateForICS(testDate);
|
|
||||||
|
|
||||||
// Preserve time portion from original event
|
|
||||||
const timePart = event.start.includes('T') ?
|
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
|
||||||
const endTimePart = event.end.includes('T') ?
|
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
|
||||||
|
|
||||||
newEvent.start = eventDate + timePart;
|
|
||||||
newEvent.end = eventDate + endTimePart;
|
|
||||||
expandedEvents.push(newEvent);
|
|
||||||
}
|
|
||||||
day++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
|
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
|
||||||
if (bydayMatch) {
|
|
||||||
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
|
|
||||||
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
|
|
||||||
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
|
|
||||||
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const bydays = bydayMatch[1].split(',');
|
|
||||||
const dayMap = {
|
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
|
||||||
};
|
|
||||||
|
|
||||||
bydays.forEach(byday => {
|
|
||||||
const occurrence = parseInt(byday) || 1;
|
|
||||||
const dayCode = byday.slice(-2);
|
|
||||||
const dayIndex = dayMap[dayCode];
|
|
||||||
|
|
||||||
let day = 1;
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
while (day <= rangeEnd.getDate()) {
|
|
||||||
const testDate = new Date(year, month, day);
|
|
||||||
if (testDate.getDay() === dayIndex) {
|
|
||||||
count++;
|
|
||||||
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
|
|
||||||
const newEvent = {...event};
|
|
||||||
const eventDate = new Date(year, month, day);
|
|
||||||
|
|
||||||
// Preserve time portion from original event
|
|
||||||
const timePart = event.start.includes('T') ?
|
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
|
||||||
const endTimePart = event.end.includes('T') ?
|
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
|
||||||
|
|
||||||
newEvent.start = formatDateForICS(eventDate) + timePart;
|
|
||||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
|
||||||
expandedEvents.push(newEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
day++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandedEvents.length > 0 ? expandedEvents : [event];
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Kalender initialisieren
|
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
|
||||||
let currentYear, currentMonth;
|
add(toOccurrence(event, event.startDate, event.endDate));
|
||||||
const currentMonthElem = document.getElementById("current-month");
|
}
|
||||||
const calendarBody = document.getElementById("calendar-body");
|
}
|
||||||
const eventPanel = document.getElementById("event-panel");
|
|
||||||
const eventDateElem = document.getElementById("event-date");
|
for (const occurrences of Object.values(byDate)) {
|
||||||
const eventDetailsElem = document.getElementById("event-details");
|
occurrences.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
document.getElementById("prev-month").addEventListener("click", function(){
|
|
||||||
currentMonth--;
|
return byDate;
|
||||||
if (currentMonth < 0) {
|
}
|
||||||
currentMonth = 11;
|
|
||||||
currentYear--;
|
function updateEventsForMonth(year, month) {
|
||||||
}
|
eventsByDate = occurrencesOfMonth(year, month);
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
renderCalendar(year, month);
|
||||||
});
|
}
|
||||||
document.getElementById("next-month").addEventListener("click", function(){
|
|
||||||
currentMonth++;
|
function renderCalendar(year, month) {
|
||||||
if (currentMonth > 11) {
|
// Setze die Monatsbeschriftung (in Deutsch)
|
||||||
currentMonth = 0;
|
currentMonthElem.textContent = monthNames[month] + " " + year;
|
||||||
currentYear++;
|
calendarBody.innerHTML = "";
|
||||||
}
|
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
let firstDay = new Date(year, month, 1);
|
||||||
|
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
|
||||||
|
let daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
let row = document.createElement("tr");
|
||||||
|
// Leere Zellen vor dem 1. Tag
|
||||||
|
for (let i = 0; i < firstDayIndex; i++) {
|
||||||
|
let cell = document.createElement("td");
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tage hinzufügen
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
if (row.children.length === 7) {
|
||||||
|
calendarBody.appendChild(row);
|
||||||
|
row = document.createElement("tr");
|
||||||
|
}
|
||||||
|
let cell = document.createElement("td");
|
||||||
|
cell.innerHTML = "<strong>" + day + "</strong>";
|
||||||
|
|
||||||
|
let dayStr = day < 10 ? "0" + day : day;
|
||||||
|
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
|
||||||
|
let dateKey = year + "-" + monthStr + "-" + dayStr;
|
||||||
|
|
||||||
|
if (eventsByDate[dateKey]) {
|
||||||
|
let dotsContainer = document.createElement("div");
|
||||||
|
dotsContainer.className = "event-dots-container";
|
||||||
|
cell.classList.add("has-event");
|
||||||
|
|
||||||
|
// Gruppe Events nach Typ
|
||||||
|
const events = eventsByDate[dateKey];
|
||||||
|
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
|
||||||
|
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
|
||||||
|
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
|
||||||
|
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
|
||||||
|
const hasRegular = events.some(e => {
|
||||||
|
const title = e.summary.toLowerCase();
|
||||||
|
return !title.includes("members only") &&
|
||||||
|
!title.includes("subbotnik") &&
|
||||||
|
!title.includes("bastelabend") &&
|
||||||
|
!title.includes("spieleabend");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Füge Dots entsprechend der Event-Typen hinzu
|
||||||
|
if (hasMembersOnly) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-red";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-orange";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
if (hasRegular) {
|
||||||
|
let dot = document.createElement("div");
|
||||||
|
dot.className = "event-dot event-dot-greenyellow";
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(dotsContainer);
|
||||||
|
cell.dataset.dateKey = dateKey;
|
||||||
|
cell.addEventListener("click", function() {
|
||||||
|
// Clear previous selections
|
||||||
|
document.querySelectorAll('.selected-day').forEach(el => {
|
||||||
|
el.classList.remove('selected-day');
|
||||||
});
|
});
|
||||||
|
cell.classList.add('selected-day');
|
||||||
function updateEventsForMonth(year, month) {
|
showEventDetails(dateKey);
|
||||||
// Clear existing events for this month view
|
});
|
||||||
eventsByDate = {};
|
}
|
||||||
|
row.appendChild(cell);
|
||||||
// Process each event, expanding recurring ones
|
}
|
||||||
events.forEach(ev => {
|
// Falls die letzte Zeile nicht komplett ist
|
||||||
if (ev.rrule) {
|
while (row.children.length < 7) {
|
||||||
// For recurring events, expand them for current month
|
let cell = document.createElement("td");
|
||||||
const expandedEvents = expandRecurringEvents(ev, year, month);
|
row.appendChild(cell);
|
||||||
expandedEvents.forEach(expandedEv => {
|
}
|
||||||
let dateKey = parseDateString(expandedEv.start);
|
calendarBody.appendChild(row);
|
||||||
if (dateKey) {
|
}
|
||||||
if (!eventsByDate[dateKey]) {
|
|
||||||
eventsByDate[dateKey] = [];
|
/**
|
||||||
}
|
* Build the entry of a single event in the detail panel.
|
||||||
eventsByDate[dateKey].push(expandedEv);
|
*
|
||||||
}
|
* @param {Object} occurrence The occurrence to show
|
||||||
});
|
* @returns {HTMLElement} The entry
|
||||||
} else {
|
*/
|
||||||
// For regular events, check if they fall in current month
|
function renderEventItem(occurrence) {
|
||||||
let dateKey = parseDateString(ev.start);
|
const item = document.createElement("div");
|
||||||
if (dateKey) {
|
item.className = "event-item";
|
||||||
// Check if this event belongs to current month view
|
|
||||||
const eventYear = parseInt(dateKey.split('-')[0]);
|
const title = document.createElement("div");
|
||||||
const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
|
title.className = "event-title";
|
||||||
|
|
||||||
if (eventYear === year && eventMonth === month) {
|
if (occurrence.url) {
|
||||||
if (!eventsByDate[dateKey]) {
|
const link = document.createElement("a");
|
||||||
eventsByDate[dateKey] = [];
|
link.href = occurrence.url;
|
||||||
}
|
link.textContent = occurrence.summary;
|
||||||
eventsByDate[dateKey].push(ev);
|
title.appendChild(link);
|
||||||
}
|
} else {
|
||||||
}
|
title.textContent = occurrence.summary;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
item.appendChild(title);
|
||||||
renderCalendar(year, month);
|
|
||||||
}
|
const time = document.createElement("div");
|
||||||
|
time.className = "event-time";
|
||||||
function renderCalendar(year, month) {
|
time.textContent = formatTimeRange(occurrence);
|
||||||
// Setze die Monatsbeschriftung (in Deutsch)
|
item.appendChild(time);
|
||||||
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
|
||||||
currentMonthElem.textContent = monthNames[month] + " " + year;
|
if (occurrence.description) {
|
||||||
calendarBody.innerHTML = "";
|
const description = document.createElement("div");
|
||||||
|
description.className = "event-description";
|
||||||
let firstDay = new Date(year, month, 1);
|
description.textContent = occurrence.description;
|
||||||
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
|
item.appendChild(description);
|
||||||
let daysInMonth = new Date(year, month + 1, 0).getDate();
|
}
|
||||||
|
|
||||||
let row = document.createElement("tr");
|
return item;
|
||||||
// Leere Zellen vor dem 1. Tag
|
}
|
||||||
for (let i = 0; i < firstDayIndex; i++){
|
|
||||||
let cell = document.createElement("td");
|
function showEventDetails(dateKey) {
|
||||||
row.appendChild(cell);
|
const occurrences = eventsByDate[dateKey];
|
||||||
}
|
eventDateElem.textContent = formatDate(dateKey);
|
||||||
|
eventDetailsElem.innerHTML = "";
|
||||||
// Tage hinzufügen
|
|
||||||
for (let day = 1; day <= daysInMonth; day++){
|
if (occurrences && occurrences.length > 0) {
|
||||||
if (row.children.length === 7) {
|
for (const occurrence of occurrences) {
|
||||||
calendarBody.appendChild(row);
|
eventDetailsElem.appendChild(renderEventItem(occurrence));
|
||||||
row = document.createElement("tr");
|
}
|
||||||
}
|
} else {
|
||||||
let cell = document.createElement("td");
|
let noEvents = document.createElement("div");
|
||||||
cell.innerHTML = "<strong>" + day + "</strong>";
|
noEvents.className = "no-events";
|
||||||
|
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
|
||||||
let dayStr = day < 10 ? "0" + day : day;
|
eventDetailsElem.appendChild(noEvents);
|
||||||
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
|
}
|
||||||
let dateKey = year + "-" + monthStr + "-" + dayStr;
|
|
||||||
|
eventPanel.style.display = "block";
|
||||||
if (eventsByDate[dateKey]) {
|
}
|
||||||
let dotsContainer = document.createElement("div");
|
|
||||||
dotsContainer.className = "event-dots-container";
|
function formatDate(dateStr) {
|
||||||
cell.classList.add("has-event");
|
// Convert YYYY-MM-DD to DD.MM.YYYY
|
||||||
|
const parts = dateStr.split("-");
|
||||||
// Gruppe Events nach Typ
|
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
const events = eventsByDate[dateKey];
|
}
|
||||||
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
|
|
||||||
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
|
/**
|
||||||
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
|
* Describe when an occurrence takes place, in Berlin time.
|
||||||
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
|
*
|
||||||
const hasRegular = events.some(e => {
|
* @param {Object} occurrence The occurrence to describe
|
||||||
const title = e.summary.toLowerCase();
|
* @returns {string} The description
|
||||||
return !title.includes("members only") &&
|
*/
|
||||||
!title.includes("subbotnik") &&
|
function formatTimeRange(occurrence) {
|
||||||
!title.includes("bastelabend") &&
|
if (occurrence.allDay) {
|
||||||
!title.includes("spieleabend");
|
return "Ganztägig";
|
||||||
});
|
}
|
||||||
|
|
||||||
// Füge Dots entsprechend der Event-Typen hinzu
|
const start = timeOfDayFormat.format(occurrence.start);
|
||||||
if (hasMembersOnly) {
|
const end = timeOfDayFormat.format(occurrence.end);
|
||||||
let dot = document.createElement("div");
|
|
||||||
dot.className = "event-dot event-dot-red";
|
return `Start: ${start}, End: ${end}`;
|
||||||
dotsContainer.appendChild(dot);
|
}
|
||||||
}
|
|
||||||
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
let dot = document.createElement("div");
|
currentMonthElem = document.getElementById("current-month");
|
||||||
dot.className = "event-dot event-dot-orange";
|
calendarBody = document.getElementById("calendar-body");
|
||||||
dotsContainer.appendChild(dot);
|
eventPanel = document.getElementById("event-panel");
|
||||||
}
|
eventDateElem = document.getElementById("event-date");
|
||||||
if (hasRegular) {
|
eventDetailsElem = document.getElementById("event-details");
|
||||||
let dot = document.createElement("div");
|
|
||||||
dot.className = "event-dot event-dot-greenyellow";
|
document.getElementById("prev-month").addEventListener("click", function() {
|
||||||
dotsContainer.appendChild(dot);
|
currentMonth--;
|
||||||
}
|
if (currentMonth < 0) {
|
||||||
|
currentMonth = 11;
|
||||||
cell.appendChild(dotsContainer);
|
currentYear--;
|
||||||
cell.dataset.dateKey = dateKey;
|
}
|
||||||
cell.addEventListener("click", function() {
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
// Clear previous selections
|
});
|
||||||
document.querySelectorAll('.selected-day').forEach(el => {
|
document.getElementById("next-month").addEventListener("click", function() {
|
||||||
el.classList.remove('selected-day');
|
currentMonth++;
|
||||||
});
|
if (currentMonth > 11) {
|
||||||
cell.classList.add('selected-day');
|
currentMonth = 0;
|
||||||
showEventDetails(dateKey);
|
currentYear++;
|
||||||
});
|
}
|
||||||
}
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
row.appendChild(cell);
|
});
|
||||||
}
|
|
||||||
// Falls die letzte Zeile nicht komplett ist
|
// Show the grid of the current month right away, the events are filled in
|
||||||
while (row.children.length < 7) {
|
// once the calendar has been loaded.
|
||||||
let cell = document.createElement("td");
|
const today = new Date();
|
||||||
row.appendChild(cell);
|
currentYear = today.getFullYear();
|
||||||
}
|
currentMonth = today.getMonth();
|
||||||
calendarBody.appendChild(row);
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
}
|
|
||||||
|
fetch(icsUrl)
|
||||||
function createEventLink(eventTitle) {
|
.then(response => {
|
||||||
if (eventTitle.startsWith("Datengarten")) {
|
// Without this an error page would be handed to the parser below, which
|
||||||
// Extract the number after "Datengarten "
|
// then fails with a confusing complaint about the calendar syntax.
|
||||||
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
|
if (!response.ok) {
|
||||||
if (match && match[1]) {
|
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
|
||||||
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
|
}
|
||||||
}
|
|
||||||
}
|
return response.text();
|
||||||
|
})
|
||||||
// For other titles, convert to lowercase and use as path
|
.then(icsText => {
|
||||||
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
calendar = new ICAL.Component(ICAL.parse(icsText));
|
||||||
return `https://berlin.ccc.de/page/${slug}/`;
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
}
|
})
|
||||||
|
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||||
function showEventDetails(dateKey) {
|
|
||||||
const events = eventsByDate[dateKey];
|
|
||||||
eventDateElem.textContent = formatDate(dateKey);
|
|
||||||
eventDetailsElem.innerHTML = "";
|
|
||||||
|
|
||||||
if (events && events.length > 0) {
|
|
||||||
events.forEach(ev => {
|
|
||||||
let eventItem = document.createElement("div");
|
|
||||||
eventItem.className = "event-item";
|
|
||||||
|
|
||||||
let eventTitle = document.createElement("div");
|
|
||||||
eventTitle.className = "event-title";
|
|
||||||
|
|
||||||
// Create a link for the event title
|
|
||||||
let titleLink = document.createElement("h");
|
|
||||||
titleLink.textContent = ev.summary;
|
|
||||||
titleLink.target = "_blank";
|
|
||||||
eventTitle.appendChild(titleLink);
|
|
||||||
|
|
||||||
eventItem.appendChild(eventTitle);
|
|
||||||
|
|
||||||
let eventTime = document.createElement("div");
|
|
||||||
eventTime.className = "event-time";
|
|
||||||
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
|
|
||||||
eventItem.appendChild(eventTime);
|
|
||||||
|
|
||||||
if (ev.description) {
|
|
||||||
let eventDescription = document.createElement("div");
|
|
||||||
eventDescription.className = "event-description";
|
|
||||||
|
|
||||||
// Check if the description is a URL and make it a clickable link
|
|
||||||
if (ev.description.trim().startsWith('http')) {
|
|
||||||
let linkElement = document.createElement("a");
|
|
||||||
linkElement.href = ev.description.trim();
|
|
||||||
linkElement.textContent = ev.description.trim();
|
|
||||||
linkElement.target = "_blank";
|
|
||||||
eventDescription.innerHTML = '';
|
|
||||||
eventDescription.appendChild(linkElement);
|
|
||||||
} else {
|
|
||||||
eventDescription.textContent = ev.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
eventItem.appendChild(eventDescription);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventDetailsElem.appendChild(eventItem);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let noEvents = document.createElement("div");
|
|
||||||
noEvents.className = "no-events";
|
|
||||||
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
|
|
||||||
eventDetailsElem.appendChild(noEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventPanel.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
// Convert YYYY-MM-DD to DD.MM.YYYY
|
|
||||||
const parts = dateStr.split("-");
|
|
||||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(icsTimeStr) {
|
|
||||||
// Format time for display
|
|
||||||
if (!icsTimeStr) return "";
|
|
||||||
|
|
||||||
if (icsTimeStr.length === 8) {
|
|
||||||
// All-day event
|
|
||||||
return "Ganztägig";
|
|
||||||
} else if (icsTimeStr.includes("T")) {
|
|
||||||
// Time-specific event (with or without timezone)
|
|
||||||
const timeStart = icsTimeStr.indexOf("T") + 1;
|
|
||||||
const hour = icsTimeStr.substring(timeStart, timeStart + 2);
|
|
||||||
const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4);
|
|
||||||
return `${hour}:${minute}`;
|
|
||||||
}
|
|
||||||
return icsTimeStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateForICS(date) {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
||||||
const day = date.getDate().toString().padStart(2, '0');
|
|
||||||
return `${year}${month}${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ICS-Datei abrufen und Events verarbeiten
|
|
||||||
fetch('/calendars/all.ics')
|
|
||||||
.then(response => response.text())
|
|
||||||
.then(data => {
|
|
||||||
events = parseICS(data);
|
|
||||||
|
|
||||||
// Initialize with current date
|
|
||||||
let today = new Date();
|
|
||||||
currentYear = today.getFullYear();
|
|
||||||
currentMonth = today.getMonth();
|
|
||||||
|
|
||||||
// Process events for current month
|
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{{ $js := resources.Get "js/calendar.js" }}
|
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
|
||||||
{{ $css := resources.Get "css/calendar.css" }}
|
{{ $css := resources.Get "css/calendar.css" }}
|
||||||
|
|
||||||
<div class="calendar-container">
|
<div class="calendar-container">
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ with $js }}
|
{{ with $js }}
|
||||||
<script src="{{ .RelPermalink }}"></script>
|
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div id="calendar">
|
<div id="calendar">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}}
|
||||||
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
|
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
|
||||||
<table id="upcoming" class="table table-condensed"></table>
|
<table id="upcoming" class="table table-condensed"></table>
|
||||||
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue