Compare commits

..
10 changed files with 475 additions and 10279 deletions

View file

@ -29,25 +29,20 @@ This is the website of the CCCB.
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running. Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the The *"Nächste Veranstaltungen"* table on the home page is generated by post-processing in `./build.sh`, not by Hugo, so
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not** it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to
generated by Hugo and is not part of this repo — it is published separately on the web server. Locally those two ready the site for upload, run:
tables therefore stay empty; check them on the [staging site](https://staging.berlin.ccc.de/) instead.
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
The site must not make the browser load anything from an external server, so third party JavaScript is checked into
`assets/js/vendor/` and bundled in by Hugo instead of being pulled from a CDN. See the README there before updating it.
To build the site for upload, run:
```shell ```shell
./build.sh ./build.sh
python3 -m http.server -d public 1313
``` ```
This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead: `build.sh` replaces the `CALENDAR` placeholder in `index.html` with the upcoming-events table. It depends on Python
`nix build '.?submodules=1#production-content'` with the `icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` locale (used to format weekday
names). Inside `nix develop` these are provided automatically.
To build with *nix*: `nix build '.?submodules=1#production-content'`
## Making a change ## Making a change
@ -64,7 +59,8 @@ This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`.
## Nix stuff ## Nix stuff
- After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` should work - After entering the shell with `nix develop`, hugo is available and `hugo serve` should work
- Python including required packages will be available, so the `build.sh` should work without a venv
- You can build the staging and production builds with `nix build .#staging-content` and - You can build the staging and production builds with `nix build .#staging-content` and
`nix build .#production-content` `nix build .#production-content`
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last - Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last

View file

@ -107,10 +107,6 @@
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);

View file

@ -1,36 +0,0 @@
/* 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;
}

View file

@ -1,418 +1,449 @@
import ICAL from "./vendor/ical.js"; document.addEventListener('DOMContentLoaded', function() {
(function(){
let events = [];
let eventsByDate = {};
const icsUrl = "/calendars/all.ics"; // Funktion zum Parsen der ICS-Datei
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);
// The club is in Berlin, so the calendar shows Berlin days and Berlin times, // Handle properties with parameters (like TZID)
// no matter which time zone the browser of the visitor is set to. const baseKey = key.split(";")[0];
const timeZone = "Europe/Berlin";
const monthNames = [ if (baseKey === "DTSTART") {
"Januar", "Februar", "März", "April", "Mai", "Juni", event.start = value;
"Juli", "August", "September", "Oktober", "November", "Dezember", event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
]; } else if (baseKey === "DTEND") {
event.end = value;
const dayKeyFormat = new Intl.DateTimeFormat("en-US", { event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
timeZone, } else if (baseKey === "SUMMARY") {
year: "numeric", event.summary = value;
month: "2-digit", } else if (baseKey === "DESCRIPTION") {
day: "2-digit", event.description = value;
}); } else if (baseKey === "RRULE") {
event.rrule = value;
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", { }
timeZone, }
hour: "2-digit", }
minute: "2-digit", });
}); return events;
let calendar = null;
let eventsByDate = {};
let currentYear;
let currentMonth;
let currentMonthElem;
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;
} }
// Details resolve time, name and URL of an occurrence that was // Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
// modified via RECURRENCE-ID. function parseDateString(icsDateStr) {
const details = event.getOccurrenceDetails(occurrence); // Handle different date formats
if (!icsDateStr) return null;
if (details.endDate.toJSDate() > from) { // For basic date format: YYYYMMDD
add(toOccurrence(details.item, details.startDate, details.endDate)); if (icsDateStr.length === 8) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
else if (icsDateStr.includes("T")) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
return null;
} }
}
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) {
add(toOccurrence(event, event.startDate, event.endDate));
}
}
for (const occurrences of Object.values(byDate)) { // Extract date components from different date formats
occurrences.sort((a, b) => a.start - b.start); function getDateComponents(icsDateStr) {
} if (!icsDateStr) return null;
return byDate; // Basic handling - extract YYYY, MM, DD regardless of format
} const year = parseInt(icsDateStr.substring(0, 4));
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
const day = parseInt(icsDateStr.substring(6, 8));
function updateEventsForMonth(year, month) { return { year, month, day };
eventsByDate = occurrencesOfMonth(year, month); }
renderCalendar(year, month);
}
function renderCalendar(year, month) { function expandRecurringEvents(event, year, month) {
// Setze die Monatsbeschriftung (in Deutsch) if (!event.rrule) return [event];
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1); const rruleStr = event.rrule;
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"); // Get start date components
// Leere Zellen vor dem 1. Tag const startComponents = getDateComponents(event.start);
for (let i = 0; i < firstDayIndex; i++) { if (!startComponents) return [event];
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen const startDate = new Date(
for (let day = 1; day <= daysInMonth; day++) { startComponents.year,
if (row.children.length === 7) { startComponents.month,
calendarBody.appendChild(row); startComponents.day
row = document.createElement("tr"); );
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day; const rangeStart = new Date(year, month, 1);
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1); const rangeEnd = new Date(year, month + 1, 0);
let dateKey = year + "-" + monthStr + "-" + dayStr; const expandedEvents = [];
if (eventsByDate[dateKey]) { if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
let dotsContainer = document.createElement("div"); const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
dotsContainer.className = "event-dots-container"; if (bydayMatch) {
cell.classList.add("has-event"); const dayCode = bydayMatch[1];
const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
};
const targetDay = dayMap[dayCode];
// Gruppe Events nach Typ if (targetDay !== undefined) {
const events = eventsByDate[dateKey]; // Create events for each matching day in the month
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only")); let day = 1;
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik")); while (day <= rangeEnd.getDate()) {
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend")); const testDate = new Date(year, month, day);
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend")); if (testDate.getDay() === targetDay && testDate >= startDate) {
const hasRegular = events.some(e => { const newEvent = {...event};
const title = e.summary.toLowerCase(); const eventDate = formatDateForICS(testDate);
return !title.includes("members only") &&
!title.includes("subbotnik") &&
!title.includes("bastelabend") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu // Preserve time portion from original event
if (hasMembersOnly) { const timePart = event.start.includes('T') ?
let dot = document.createElement("div"); event.start.substring(event.start.indexOf('T')) : '';
dot.className = "event-dot event-dot-red"; const endTimePart = event.end.includes('T') ?
dotsContainer.appendChild(dot); event.end.substring(event.end.indexOf('T')) : '';
}
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); newEvent.start = eventDate + timePart;
cell.dataset.dateKey = dateKey; newEvent.end = eventDate + endTimePart;
cell.addEventListener("click", function() { expandedEvents.push(newEvent);
// Clear previous selections }
document.querySelectorAll('.selected-day').forEach(el => { day++;
el.classList.remove('selected-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
let currentYear, currentMonth;
const currentMonthElem = document.getElementById("current-month");
const calendarBody = document.getElementById("calendar-body");
const eventPanel = document.getElementById("event-panel");
const eventDateElem = document.getElementById("event-date");
const eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function(){
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function(){
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
}); });
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
/** function updateEventsForMonth(year, month) {
* Build the entry of a single event in the detail panel. // Clear existing events for this month view
* eventsByDate = {};
* @param {Object} occurrence The occurrence to show
* @returns {HTMLElement} The entry
*/
function renderEventItem(occurrence) {
const item = document.createElement("div");
item.className = "event-item";
const title = document.createElement("div"); // Process each event, expanding recurring ones
title.className = "event-title"; events.forEach(ev => {
if (ev.rrule) {
// For recurring events, expand them for current month
const expandedEvents = expandRecurringEvents(ev, year, month);
expandedEvents.forEach(expandedEv => {
let dateKey = parseDateString(expandedEv.start);
if (dateKey) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(expandedEv);
}
});
} else {
// For regular events, check if they fall in current month
let dateKey = parseDateString(ev.start);
if (dateKey) {
// Check if this event belongs to current month view
const eventYear = parseInt(dateKey.split('-')[0]);
const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
if (occurrence.url) { if (eventYear === year && eventMonth === month) {
const link = document.createElement("a"); if (!eventsByDate[dateKey]) {
link.href = occurrence.url; eventsByDate[dateKey] = [];
link.textContent = occurrence.summary; }
title.appendChild(link); eventsByDate[dateKey].push(ev);
} else { }
title.textContent = occurrence.summary; }
} }
});
item.appendChild(title); renderCalendar(year, month);
}
const time = document.createElement("div"); function renderCalendar(year, month) {
time.className = "event-time"; // Setze die Monatsbeschriftung (in Deutsch)
time.textContent = formatTimeRange(occurrence); const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
item.appendChild(time); currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
if (occurrence.description) { let firstDay = new Date(year, month, 1);
const description = document.createElement("div"); let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
description.className = "event-description"; let daysInMonth = new Date(year, month + 1, 0).getDate();
description.textContent = occurrence.description;
item.appendChild(description);
}
return item; 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);
}
function showEventDetails(dateKey) { // Tage hinzufügen
const occurrences = eventsByDate[dateKey]; for (let day = 1; day <= daysInMonth; day++){
eventDateElem.textContent = formatDate(dateKey); if (row.children.length === 7) {
eventDetailsElem.innerHTML = ""; calendarBody.appendChild(row);
row = document.createElement("tr");
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
if (occurrences && occurrences.length > 0) { let dayStr = day < 10 ? "0" + day : day;
for (const occurrence of occurrences) { let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
eventDetailsElem.appendChild(renderEventItem(occurrence)); let dateKey = year + "-" + monthStr + "-" + dayStr;
}
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
eventPanel.style.display = "block"; if (eventsByDate[dateKey]) {
} let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
function formatDate(dateStr) { // Gruppe Events nach Typ
// Convert YYYY-MM-DD to DD.MM.YYYY const events = eventsByDate[dateKey];
const parts = dateStr.split("-"); const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
return `${parts[2]}.${parts[1]}.${parts[0]}`; 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
* Describe when an occurrence takes place, in Berlin time. if (hasMembersOnly) {
* let dot = document.createElement("div");
* @param {Object} occurrence The occurrence to describe dot.className = "event-dot event-dot-red";
* @returns {string} The description dotsContainer.appendChild(dot);
*/ }
function formatTimeRange(occurrence) { if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
if (occurrence.allDay) { let dot = document.createElement("div");
return "Ganztägig"; 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);
}
const start = timeOfDayFormat.format(occurrence.start); cell.appendChild(dotsContainer);
const end = timeOfDayFormat.format(occurrence.end); 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');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
return `Start: ${start}, End: ${end}`; function createEventLink(eventTitle) {
} if (eventTitle.startsWith("Datengarten")) {
// Extract the number after "Datengarten "
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
if (match && match[1]) {
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
}
}
document.addEventListener("DOMContentLoaded", function() { // For other titles, convert to lowercase and use as path
currentMonthElem = document.getElementById("current-month"); const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
calendarBody = document.getElementById("calendar-body"); return `https://berlin.ccc.de/page/${slug}/`;
eventPanel = document.getElementById("event-panel"); }
eventDateElem = document.getElementById("event-date");
eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function() { function showEventDetails(dateKey) {
currentMonth--; const events = eventsByDate[dateKey];
if (currentMonth < 0) { eventDateElem.textContent = formatDate(dateKey);
currentMonth = 11; eventDetailsElem.innerHTML = "";
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function() {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
// Show the grid of the current month right away, the events are filled in if (events && events.length > 0) {
// once the calendar has been loaded. events.forEach(ev => {
const today = new Date(); let eventItem = document.createElement("div");
currentYear = today.getFullYear(); eventItem.className = "event-item";
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
fetch(icsUrl) let eventTitle = document.createElement("div");
.then(response => { eventTitle.className = "event-title";
// Without this an error page would be handed to the parser below, which
// then fails with a confusing complaint about the calendar syntax.
if (!response.ok) {
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
}
return response.text(); // Create a link for the event title
}) let titleLink = document.createElement("h");
.then(icsText => { titleLink.textContent = ev.summary;
calendar = new ICAL.Component(ICAL.parse(icsText)); titleLink.target = "_blank";
updateEventsForMonth(currentYear, currentMonth); eventTitle.appendChild(titleLink);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err)); 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));
})();
}); });

View file

@ -1,19 +0,0 @@
# Vendored JavaScript
Third party code is checked in here instead of being loaded from a CDN, so that the website does not make the visitor's
browser fetch anything from an external server.
Hugo bundles and minifies these files into the scripts that reference them, so the unminified source is checked in.
## ical.js
- Version: 2.2.1
- Source: <https://unpkg.com/ical.js@2.2.1/dist/ical.js>
- Upstream: <https://github.com/kewisch/ical.js>
- License: MPL-2.0 (see the header of `ical.js`)
To update, download the `dist/ical.js` of the wanted release and replace the file, keeping the version above in sync:
```shell
curl -o assets/js/vendor/ical.js https://unpkg.com/ical.js@<version>/dist/ical.js
```

9732
assets/js/vendor/ical.js vendored

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,9 @@ description: "Startseite CCCB mit Kurzkalender"
### Nächste Veranstaltungen ### Nächste Veranstaltungen
{{< upcoming >}} <table id="upcoming" class="table table-condensed">
</table>
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/). Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
<script type="module" src="/js/upcoming.js"></script>

View file

@ -1,4 +1,4 @@
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }} {{ $js := resources.Get "js/calendar.js" }}
{{ $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 type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script> <script src="{{ .RelPermalink }}"></script>
{{ end }} {{ end }}
<div id="calendar"> <div id="calendar">

View file

@ -1,5 +0,0 @@
{{- $css := resources.Get "css/upcoming.css" | minify | 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>
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>

113
static/js/upcoming.js Normal file
View file

@ -0,0 +1,113 @@
import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js";
/**
* Parse an ICS calendar and return upcoming event occurrences.
*
* @param {string} icsText The contents of the .ics file
* @param {Date} now Events must start after this date
* @param {number} maxEvents Maximum number of events to return
* @param {number} maxDays Maximum number of days into the future
* @returns {{start: Date, name: string, url: string}[]}
*/
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
const jcal = ICAL.parse(icsText);
const calendar = new ICAL.Component(jcal);
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
for (const component of calendar.getAllSubcomponents("vevent")) {
const event = new ICAL.Event(component);
if (!event.startDate) {
continue;
}
if (event.isRecurring()) {
const iterator = event.iterator();
while (true) {
const occurrence = iterator.next();
if (!occurrence) {
break;
}
const start = occurrence.toJSDate();
// Recurrences are chronological, so we're done
// once we pass the end of our search window.
if (start > end) {
break;
}
if (start > now) {
events.push({
start,
name: event.summary ?? "",
url: event.url ?? "",
});
}
}
} else {
const start = event.startDate.toJSDate();
if (start > now && start <= end) {
events.push({
start,
name: event.summary ?? "",
url: event.url ?? "",
});
}
}
}
// We have occurrences from multiple events, so sort them
// before applying the maximum event count.
events.sort((a, b) => a.start - b.start);
return events.slice(0, maxEvents);
}
document.addEventListener("DOMContentLoaded", () => {
const ics = "/calendars/all.ics";
const max_days = 20;
const max_items = 5;
const now = new Date();
const table = document.getElementById("upcoming");
fetch(ics)
.then(response => response.text())
.then(icsText => {
getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).replace(",", "");
colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
row.appendChild(colName);
table.appendChild(row);
});
});
});