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.
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
generated by Hugo and is not part of this repo — it is published separately on the web server. Locally those two
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:
The *"Nächste Veranstaltungen"* table on the home page is generated by post-processing in `./build.sh`, not by Hugo, so
it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to
ready the site for upload, run:
```shell
./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:
`nix build '.?submodules=1#production-content'`
`build.sh` replaces the `CALENDAR` placeholder in `index.html` with the upcoming-events table. It depends on Python
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
@ -64,7 +59,8 @@ This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`.
## 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
`nix build .#production-content`
- 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;
margin-bottom: 5px;
}
.event-description {
/* Descriptions carry their own line breaks, keep them. */
white-space: pre-line;
}
.no-events {
font-style: italic;
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,
// no matter which time zone the browser of the visitor is set to.
const timeZone = "Europe/Berlin";
// Handle properties with parameters (like TZID)
const baseKey = key.split(";")[0];
const monthNames = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
timeZone,
hour: "2-digit",
minute: "2-digit",
});
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;
if (baseKey === "DTSTART") {
event.start = value;
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} 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;
}
// Details resolve time, name and URL of an occurrence that was
// modified via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
function parseDateString(icsDateStr) {
// Handle different date formats
if (!icsDateStr) return null;
if (details.endDate.toJSDate() > from) {
add(toOccurrence(details.item, details.startDate, details.endDate));
// For basic date format: YYYYMMDD
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)) {
occurrences.sort((a, b) => a.start - b.start);
}
// Extract date components from different date formats
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) {
eventsByDate = occurrencesOfMonth(year, month);
renderCalendar(year, month);
}
return { year, month, day };
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
function expandRecurringEvents(event, year, month) {
if (!event.rrule) return [event];
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();
const rruleStr = event.rrule;
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);
}
// Get start date components
const startComponents = getDateComponents(event.start);
if (!startComponents) return [event];
// 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>";
const startDate = new Date(
startComponents.year,
startComponents.month,
startComponents.day
);
let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr;
const rangeStart = new Date(year, month, 1);
const rangeEnd = new Date(year, month + 1, 0);
const expandedEvents = [];
if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
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];
// 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");
});
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);
// 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);
}
// 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')) : '';
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');
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
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);
}
/**
* Build the entry of a single event in the detail panel.
*
* @param {Object} occurrence The occurrence to show
* @returns {HTMLElement} The entry
*/
function renderEventItem(occurrence) {
const item = document.createElement("div");
item.className = "event-item";
function updateEventsForMonth(year, month) {
// Clear existing events for this month view
eventsByDate = {};
const title = document.createElement("div");
title.className = "event-title";
// Process each event, expanding recurring ones
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) {
const link = document.createElement("a");
link.href = occurrence.url;
link.textContent = occurrence.summary;
title.appendChild(link);
} else {
title.textContent = occurrence.summary;
}
if (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(ev);
}
}
}
});
item.appendChild(title);
renderCalendar(year, month);
}
const time = document.createElement("div");
time.className = "event-time";
time.textContent = formatTimeRange(occurrence);
item.appendChild(time);
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
if (occurrence.description) {
const description = document.createElement("div");
description.className = "event-description";
description.textContent = occurrence.description;
item.appendChild(description);
}
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();
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) {
const occurrences = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
// 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>";
if (occurrences && occurrences.length > 0) {
for (const occurrence of occurrences) {
eventDetailsElem.appendChild(renderEventItem(occurrence));
}
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
let dayStr = day < 10 ? "0" + day : day;
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";
cell.classList.add("has-event");
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
// 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");
});
/**
* Describe when an occurrence takes place, in Berlin time.
*
* @param {Object} occurrence The occurrence to describe
* @returns {string} The description
*/
function formatTimeRange(occurrence) {
if (occurrence.allDay) {
return "Ganztägig";
}
// 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);
}
const start = timeOfDayFormat.format(occurrence.start);
const end = timeOfDayFormat.format(occurrence.end);
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');
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() {
currentMonthElem = document.getElementById("current-month");
calendarBody = document.getElementById("calendar-body");
eventPanel = document.getElementById("event-panel");
eventDateElem = document.getElementById("event-date");
eventDetailsElem = document.getElementById("event-details");
// For other titles, convert to lowercase and use as path
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return `https://berlin.ccc.de/page/${slug}/`;
}
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);
});
function showEventDetails(dateKey) {
const events = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
// Show the grid of the current month right away, the events are filled in
// once the calendar has been loaded.
const today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
if (events && events.length > 0) {
events.forEach(ev => {
let eventItem = document.createElement("div");
eventItem.className = "event-item";
fetch(icsUrl)
.then(response => {
// 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}`);
}
let eventTitle = document.createElement("div");
eventTitle.className = "event-title";
return response.text();
})
.then(icsText => {
calendar = new ICAL.Component(ICAL.parse(icsText));
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
// 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));
})();
});

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
{{< upcoming >}}
<table id="upcoming" class="table table-condensed">
</table>
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" }}
<div class="calendar-container">
@ -6,7 +6,7 @@
<link rel="stylesheet" href="{{ .RelPermalink }}">
{{ end }}
{{ with $js }}
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
<script src="{{ .RelPermalink }}"></script>
{{ end }}
<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);
});
});
});