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,209 +1,250 @@
import ICAL from "./vendor/ical.js"; document.addEventListener('DOMContentLoaded', function() {
(function(){
const icsUrl = "/calendars/all.ics"; let events = [];
// 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";
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 eventsByDate = {};
let currentYear;
let currentMonth;
let currentMonthElem; // Funktion zum Parsen der ICS-Datei
let calendarBody; function parseICS(icsText) {
let eventPanel; let events = [];
let eventDateElem; let lines = icsText.split(/\r?\n/);
let eventDetailsElem; 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 day a point in time falls on in Berlin. const baseKey = key.split(";")[0];
*
* @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)) { if (baseKey === "DTSTART") {
parts[part.type] = part.value; 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;
} }
return `${parts.year}-${parts.month}-${parts.day}`; // Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
function parseDateString(icsDateStr) {
// Handle different date formats
if (!icsDateStr) return null;
// 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;
} }
/** // Extract date components from different date formats
* The days an event covers, so that an event running over several days is function getDateComponents(icsDateStr) {
* shown on each of them and not only on the day it starts. if (!icsDateStr) return null;
*
* @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); // 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));
// The guard keeps a broken event from looping forever, a year of dots on the return { year, month, day };
// 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 function expandRecurringEvents(event, year, month) {
// whether daylight saving time is in effect or not. if (!event.rrule) return [event];
const [year, month, day] = key.split("-").map(Number);
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
}
return days; const rruleStr = event.rrule;
}
/** // Get start date components
* Read the URL of an event. const startComponents = getDateComponents(event.start);
* if (!startComponents) return [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") ?? "";
}
/** const startDate = new Date(
* Reduce one occurrence of an event to what the calendar displays. startComponents.year,
* startComponents.month,
* @param {ICAL.Event} event The event the occurrence belongs to startComponents.day
* @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}} const rangeStart = new Date(year, month, 1);
*/ const rangeEnd = new Date(year, month + 1, 0);
function toOccurrence(event, startDate, endDate) { const expandedEvents = [];
return {
summary: event.summary ?? "", if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
description: event.description ?? "", const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
url: eventUrl(event), if (bydayMatch) {
start: startDate.toJSDate(), const dayCode = bydayMatch[1];
end: endDate.toJSDate(), const dayMap = {
allDay: startDate.isDate, '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++;
/**
* 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);
} }
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
}; };
for (const component of calendar.getAllSubcomponents("vevent")) { bydays.forEach(byday => {
const event = new ICAL.Event(component); const occurrence = parseInt(byday) || 1;
const dayCode = byday.slice(-2);
const dayIndex = dayMap[dayCode];
// Occurrences modified via RECURRENCE-ID are reached through the event they let day = 1;
// belong to, handling them here as well would show them twice. let count = 0;
if (event.isRecurrenceException() || !event.startDate) {
continue;
}
if (event.isRecurring()) { while (day <= rangeEnd.getDate()) {
const iterator = event.iterator(); 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);
while (true) { // Preserve time portion from original event
const occurrence = iterator.next(); 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')) : '';
// Recurrences are chronological, so we are done once one starts after newEvent.start = formatDateForICS(eventDate) + timePart;
// the month. newEvent.end = formatDateForICS(eventDate) + endTimePart;
if (!occurrence || occurrence.toJSDate() >= to) { expandedEvents.push(newEvent);
break;
}
// Details resolve time, name and URL of an occurrence that was
// modified via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
if (details.endDate.toJSDate() > from) {
add(toOccurrence(details.item, details.startDate, details.endDate));
} }
} }
} else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) { day++;
add(toOccurrence(event, event.startDate, event.endDate)); }
});
} }
} }
for (const occurrences of Object.values(byDate)) { return expandedEvents.length > 0 ? expandedEvents : [event];
occurrences.sort((a, b) => a.start - b.start);
} }
return byDate; // 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);
});
function updateEventsForMonth(year, month) { function updateEventsForMonth(year, month) {
eventsByDate = occurrencesOfMonth(year, month); // Clear existing events for this month view
eventsByDate = {};
// 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 (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(ev);
}
}
}
});
renderCalendar(year, month); renderCalendar(year, month);
} }
function renderCalendar(year, month) { function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch) // 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; currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = ""; calendarBody.innerHTML = "";
@ -288,54 +329,67 @@ function renderCalendar(year, month) {
calendarBody.appendChild(row); calendarBody.appendChild(row);
} }
/** function createEventLink(eventTitle) {
* Build the entry of a single event in the detail panel. if (eventTitle.startsWith("Datengarten")) {
* // Extract the number after "Datengarten "
* @param {Object} occurrence The occurrence to show const match = eventTitle.match(/Datengarten\s+(\d+)/i);
* @returns {HTMLElement} The entry if (match && match[1]) {
*/ return `https://berlin.ccc.de/datengarten/${match[1]}/`;
function renderEventItem(occurrence) { }
const item = document.createElement("div");
item.className = "event-item";
const title = document.createElement("div");
title.className = "event-title";
if (occurrence.url) {
const link = document.createElement("a");
link.href = occurrence.url;
link.textContent = occurrence.summary;
title.appendChild(link);
} else {
title.textContent = occurrence.summary;
} }
item.appendChild(title); // For other titles, convert to lowercase and use as path
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
const time = document.createElement("div"); return `https://berlin.ccc.de/page/${slug}/`;
time.className = "event-time";
time.textContent = formatTimeRange(occurrence);
item.appendChild(time);
if (occurrence.description) {
const description = document.createElement("div");
description.className = "event-description";
description.textContent = occurrence.description;
item.appendChild(description);
}
return item;
} }
function showEventDetails(dateKey) { function showEventDetails(dateKey) {
const occurrences = eventsByDate[dateKey]; const events = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey); eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = ""; eventDetailsElem.innerHTML = "";
if (occurrences && occurrences.length > 0) { if (events && events.length > 0) {
for (const occurrence of occurrences) { events.forEach(ev => {
eventDetailsElem.appendChild(renderEventItem(occurrence)); 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 { } else {
let noEvents = document.createElement("div"); let noEvents = document.createElement("div");
noEvents.className = "no-events"; noEvents.className = "no-events";
@ -352,67 +406,44 @@ function formatDate(dateStr) {
return `${parts[2]}.${parts[1]}.${parts[0]}`; return `${parts[2]}.${parts[1]}.${parts[0]}`;
} }
/** function formatTime(icsTimeStr) {
* Describe when an occurrence takes place, in Berlin time. // Format time for display
* if (!icsTimeStr) return "";
* @param {Object} occurrence The occurrence to describe
* @returns {string} The description if (icsTimeStr.length === 8) {
*/ // All-day event
function formatTimeRange(occurrence) {
if (occurrence.allDay) {
return "Ganztägig"; 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;
} }
const start = timeOfDayFormat.format(occurrence.start); function formatDateForICS(date) {
const end = timeOfDayFormat.format(occurrence.end); const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return `Start: ${start}, End: ${end}`; const day = date.getDate().toString().padStart(2, '0');
return `${year}${month}${day}`;
} }
document.addEventListener("DOMContentLoaded", function() { // ICS-Datei abrufen und Events verarbeiten
currentMonthElem = document.getElementById("current-month"); fetch('/calendars/all.ics')
calendarBody = document.getElementById("calendar-body"); .then(response => response.text())
eventPanel = document.getElementById("event-panel"); .then(data => {
eventDateElem = document.getElementById("event-date"); events = parseICS(data);
eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function() { // Initialize with current date
currentMonth--; let today = new Date();
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);
});
// 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(); currentYear = today.getFullYear();
currentMonth = today.getMonth(); currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
fetch(icsUrl) // Process events for current month
.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}`);
}
return response.text();
})
.then(icsText => {
calendar = new ICAL.Component(ICAL.parse(icsText));
updateEventsForMonth(currentYear, currentMonth); updateEventsForMonth(currentYear, currentMonth);
}) })
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err)); .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);
});
});
});