Compare commits
19 changed files with 579 additions and 10711 deletions
|
|
@ -1,10 +1,5 @@
|
|||
name: deploy blog
|
||||
|
||||
# Create secrets with:
|
||||
# KNOWN_HOST=$(ssh-keyscan -H www.berlin.ccc.de | grep ssh-ed25519 | base64 -w0)
|
||||
# SSH_PRIVATE_KEY_PRODUCTION=$(agenix -d id_ed25519_www-production.age | base64 -w0)
|
||||
# SSH_PRIVATE_KEY_STAGING=$(agenix -d id_ed25519_www-staging.age | base64 -w0)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -202,8 +202,3 @@ $RECYCLE.BIN/
|
|||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
|
||||
|
||||
### CCCB ###
|
||||
# The calendar the site reads its events from is published next to the site and
|
||||
# only downloaded here to look at the site locally, see the README.
|
||||
/static/calendars/
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -29,34 +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. Without it both tables
|
||||
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
|
||||
locally, under `hugo serve` as well as in a built site:
|
||||
|
||||
```shell
|
||||
mkdir -p static/calendars
|
||||
curl -o static/calendars/all.ics https://berlin.ccc.de/calendars/all.ics
|
||||
```
|
||||
|
||||
The file is only there to look at the site, it is not checked in. Download it again whenever you want the events that
|
||||
are currently published.
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -73,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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,406 +1,449 @@
|
|||
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
(function(){
|
||||
let events = [];
|
||||
let eventsByDate = {};
|
||||
|
||||
// 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";
|
||||
// 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);
|
||||
|
||||
const monthNames = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
];
|
||||
// Handle properties with parameters (like TZID)
|
||||
const baseKey = key.split(";")[0];
|
||||
|
||||
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
||||
function parseDateString(icsDateStr) {
|
||||
// Handle different date formats
|
||||
if (!icsDateStr) return null;
|
||||
|
||||
let calendar = null;
|
||||
let eventsByDate = {};
|
||||
let currentYear;
|
||||
let currentMonth;
|
||||
// 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;
|
||||
}
|
||||
|
||||
let currentMonthElem;
|
||||
let calendarBody;
|
||||
let eventPanel;
|
||||
let eventDateElem;
|
||||
let eventDetailsElem;
|
||||
// Extract date components from different date formats
|
||||
function getDateComponents(icsDateStr) {
|
||||
if (!icsDateStr) return null;
|
||||
|
||||
/**
|
||||
* 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 = {};
|
||||
// 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));
|
||||
|
||||
for (const part of dayKeyFormat.formatToParts(date)) {
|
||||
parts[part.type] = part.value;
|
||||
}
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
function expandRecurringEvents(event, year, month) {
|
||||
if (!event.rrule) return [event];
|
||||
|
||||
/**
|
||||
* The day an occurrence time falls on in Berlin.
|
||||
*
|
||||
* A date has neither a time nor a zone, its digits are the day itself.
|
||||
* toJSDate() would read them as midnight in the zone of the browser, which
|
||||
* far enough east or west of Berlin lands on the day before or after.
|
||||
*
|
||||
* @param {ICAL.Time} time The time
|
||||
* @returns {string} The day as "YYYY-MM-DD"
|
||||
*/
|
||||
function timeDayKey(time) {
|
||||
if (time.isDate) {
|
||||
const month = String(time.month).padStart(2, "0");
|
||||
const day = String(time.day).padStart(2, "0");
|
||||
const rruleStr = event.rrule;
|
||||
|
||||
return `${time.year}-${month}-${day}`;
|
||||
}
|
||||
// Get start date components
|
||||
const startComponents = getDateComponents(event.start);
|
||||
if (!startComponents) return [event];
|
||||
|
||||
return dayKey(time.toJSDate());
|
||||
}
|
||||
const startDate = new Date(
|
||||
startComponents.year,
|
||||
startComponents.month,
|
||||
startComponents.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 {ICAL.Time} start Start of the event
|
||||
* @param {ICAL.Time} 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 last = end.clone();
|
||||
const rangeStart = new Date(year, month, 1);
|
||||
const rangeEnd = new Date(year, month + 1, 0);
|
||||
const expandedEvents = [];
|
||||
|
||||
if (end.isDate) {
|
||||
last.adjust(-1, 0, 0, 0);
|
||||
} else {
|
||||
last.adjust(0, 0, 0, -1);
|
||||
}
|
||||
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];
|
||||
|
||||
const lastKey = timeDayKey(last);
|
||||
const days = [];
|
||||
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);
|
||||
|
||||
let key = timeDayKey(start);
|
||||
// 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')) : '';
|
||||
|
||||
// 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);
|
||||
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
|
||||
};
|
||||
|
||||
// The keys sort as the days do, so this also stops an event whose end lies
|
||||
// before its start after the day it starts on.
|
||||
if (key >= lastKey) {
|
||||
break;
|
||||
}
|
||||
bydays.forEach(byday => {
|
||||
const occurrence = parseInt(byday) || 1;
|
||||
const dayCode = byday.slice(-2);
|
||||
const dayIndex = dayMap[dayCode];
|
||||
|
||||
// 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)));
|
||||
}
|
||||
let day = 1;
|
||||
let count = 0;
|
||||
|
||||
return days;
|
||||
}
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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, days: string[]}}
|
||||
*/
|
||||
function toOccurrence(event, startDate, endDate) {
|
||||
return {
|
||||
summary: event.summary ?? "",
|
||||
description: event.description ?? "",
|
||||
url: eventUrl(event),
|
||||
start: startDate.toJSDate(),
|
||||
end: endDate.toJSDate(),
|
||||
allDay: startDate.isDate,
|
||||
// Taken from the ICAL times, which still know whether they name a day or a
|
||||
// point in time; the JS dates above no longer do.
|
||||
days: daysCovered(startDate, endDate),
|
||||
};
|
||||
}
|
||||
// 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')) : '';
|
||||
|
||||
/**
|
||||
* 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 = {};
|
||||
newEvent.start = formatDateForICS(eventDate) + timePart;
|
||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
||||
expandedEvents.push(newEvent);
|
||||
}
|
||||
}
|
||||
day++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!calendar) {
|
||||
return byDate;
|
||||
}
|
||||
return expandedEvents.length > 0 ? expandedEvents : [event];
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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");
|
||||
|
||||
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
|
||||
|
||||
const add = (occurrence) => {
|
||||
for (const key of occurrence.days) {
|
||||
if (!key.startsWith(monthPrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byDate[key]) {
|
||||
byDate[key] = [];
|
||||
}
|
||||
|
||||
byDate[key].push(occurrence);
|
||||
}
|
||||
};
|
||||
|
||||
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
|
||||
add(toOccurrence(event, startDate, endDate));
|
||||
}
|
||||
|
||||
for (const occurrences of Object.values(byDate)) {
|
||||
occurrences.sort((a, b) => {
|
||||
// An all day event has no time of day to sort by, the JS date of its
|
||||
// start is midnight in the zone of the browser. Put it first instead.
|
||||
if (a.allDay !== b.allDay) {
|
||||
return a.allDay ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.start - b.start;
|
||||
});
|
||||
}
|
||||
|
||||
return byDate;
|
||||
}
|
||||
|
||||
function updateEventsForMonth(year, month) {
|
||||
eventsByDate = occurrencesOfMonth(year, month);
|
||||
renderCalendar(year, month);
|
||||
}
|
||||
|
||||
function renderCalendar(year, month) {
|
||||
// Setze die Monatsbeschriftung (in Deutsch)
|
||||
currentMonthElem.textContent = monthNames[month] + " " + year;
|
||||
calendarBody.innerHTML = "";
|
||||
|
||||
let firstDay = new Date(year, month, 1);
|
||||
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
|
||||
let daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
let row = document.createElement("tr");
|
||||
// Leere Zellen vor dem 1. Tag
|
||||
for (let i = 0; i < firstDayIndex; i++) {
|
||||
let cell = document.createElement("td");
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
// Tage hinzufügen
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
if (row.children.length === 7) {
|
||||
calendarBody.appendChild(row);
|
||||
row = document.createElement("tr");
|
||||
}
|
||||
let cell = document.createElement("td");
|
||||
cell.innerHTML = "<strong>" + day + "</strong>";
|
||||
|
||||
let dayStr = day < 10 ? "0" + day : day;
|
||||
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
|
||||
let dateKey = year + "-" + monthStr + "-" + dayStr;
|
||||
|
||||
if (eventsByDate[dateKey]) {
|
||||
let dotsContainer = document.createElement("div");
|
||||
dotsContainer.className = "event-dots-container";
|
||||
cell.classList.add("has-event");
|
||||
|
||||
// Gruppe Events nach Typ
|
||||
const events = eventsByDate[dateKey];
|
||||
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
|
||||
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
|
||||
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
|
||||
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
|
||||
const hasRegular = events.some(e => {
|
||||
const title = e.summary.toLowerCase();
|
||||
return !title.includes("members only") &&
|
||||
!title.includes("subbotnik") &&
|
||||
!title.includes("bastelabend") &&
|
||||
!title.includes("spieleabend");
|
||||
});
|
||||
|
||||
// Füge Dots entsprechend der Event-Typen hinzu
|
||||
if (hasMembersOnly) {
|
||||
let dot = document.createElement("div");
|
||||
dot.className = "event-dot event-dot-red";
|
||||
dotsContainer.appendChild(dot);
|
||||
}
|
||||
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
|
||||
let dot = document.createElement("div");
|
||||
dot.className = "event-dot event-dot-orange";
|
||||
dotsContainer.appendChild(dot);
|
||||
}
|
||||
if (hasRegular) {
|
||||
let dot = document.createElement("div");
|
||||
dot.className = "event-dot event-dot-greenyellow";
|
||||
dotsContainer.appendChild(dot);
|
||||
}
|
||||
|
||||
cell.appendChild(dotsContainer);
|
||||
cell.dataset.dateKey = dateKey;
|
||||
cell.addEventListener("click", function() {
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.selected-day').forEach(el => {
|
||||
el.classList.remove('selected-day');
|
||||
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 `Beginn: ${start}, Ende: ${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";
|
||||
|
||||
loadCalendar()
|
||||
.then(loaded => {
|
||||
calendar = loaded;
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
})
|
||||
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||
let eventTitle = document.createElement("div");
|
||||
eventTitle.className = "event-title";
|
||||
|
||||
// Create a link for the event title
|
||||
let titleLink = document.createElement("h");
|
||||
titleLink.textContent = ev.summary;
|
||||
titleLink.target = "_blank";
|
||||
eventTitle.appendChild(titleLink);
|
||||
|
||||
eventItem.appendChild(eventTitle);
|
||||
|
||||
let eventTime = document.createElement("div");
|
||||
eventTime.className = "event-time";
|
||||
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
|
||||
eventItem.appendChild(eventTime);
|
||||
|
||||
if (ev.description) {
|
||||
let eventDescription = document.createElement("div");
|
||||
eventDescription.className = "event-description";
|
||||
|
||||
// Check if the description is a URL and make it a clickable link
|
||||
if (ev.description.trim().startsWith('http')) {
|
||||
let linkElement = document.createElement("a");
|
||||
linkElement.href = ev.description.trim();
|
||||
linkElement.textContent = ev.description.trim();
|
||||
linkElement.target = "_blank";
|
||||
eventDescription.innerHTML = '';
|
||||
eventDescription.appendChild(linkElement);
|
||||
} else {
|
||||
eventDescription.textContent = ev.description;
|
||||
}
|
||||
|
||||
eventItem.appendChild(eventDescription);
|
||||
}
|
||||
|
||||
eventDetailsElem.appendChild(eventItem);
|
||||
});
|
||||
} else {
|
||||
let noEvents = document.createElement("div");
|
||||
noEvents.className = "no-events";
|
||||
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
|
||||
eventDetailsElem.appendChild(noEvents);
|
||||
}
|
||||
|
||||
eventPanel.style.display = "block";
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
// Convert YYYY-MM-DD to DD.MM.YYYY
|
||||
const parts = dateStr.split("-");
|
||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
}
|
||||
|
||||
function formatTime(icsTimeStr) {
|
||||
// Format time for display
|
||||
if (!icsTimeStr) return "";
|
||||
|
||||
if (icsTimeStr.length === 8) {
|
||||
// All-day event
|
||||
return "Ganztägig";
|
||||
} else if (icsTimeStr.includes("T")) {
|
||||
// Time-specific event (with or without timezone)
|
||||
const timeStart = icsTimeStr.indexOf("T") + 1;
|
||||
const hour = icsTimeStr.substring(timeStart, timeStart + 2);
|
||||
const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4);
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
return icsTimeStr;
|
||||
}
|
||||
|
||||
function formatDateForICS(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
// ICS-Datei abrufen und Events verarbeiten
|
||||
fetch('/calendars/all.ics')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
events = parseICS(data);
|
||||
|
||||
// Initialize with current date
|
||||
let today = new Date();
|
||||
currentYear = today.getFullYear();
|
||||
currentMonth = today.getMonth();
|
||||
|
||||
// Process events for current month
|
||||
updateEventsForMonth(currentYear, currentMonth);
|
||||
})
|
||||
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
|
||||
})();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
import ICAL from "./vendor/ical.js";
|
||||
|
||||
// The calendar is published next to the site and is not built from this
|
||||
// repository, see the README.
|
||||
const icsUrl = "/calendars/all.ics";
|
||||
|
||||
/**
|
||||
* Read the URL of an event.
|
||||
*
|
||||
* ICAL.Event does not expose the URL property, so read it from the component.
|
||||
*
|
||||
* The value ends up in the href of a link, and the calendar is exported from a
|
||||
* CalDAV server, so whoever may write to it decides what that value is. A
|
||||
* "javascript:" URL there would run on our page as soon as a visitor clicks
|
||||
* the event, so hand on nothing but http and https.
|
||||
*
|
||||
* @param {ICAL.Event} event The event to read the URL of
|
||||
* @returns {string} The URL, empty when the event has none or it is not http(s)
|
||||
*/
|
||||
export function eventUrl(event) {
|
||||
const url = event.component.getFirstPropertyValue("url") ?? "";
|
||||
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// A relative URL is resolved against the page and keeps its scheme.
|
||||
const { protocol } = new URL(url, document.baseURI);
|
||||
|
||||
return protocol === "http:" || protocol === "https:" ? url : "";
|
||||
} catch {
|
||||
// Not a URL at all.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the occurrences that were modified on their own by the UID of the event
|
||||
* they belong to.
|
||||
*
|
||||
* Unless it is told which exceptions belong to an event, ICAL.Event relates
|
||||
* every VEVENT with a RECURRENCE-ID in the file to every recurring event, and
|
||||
* it keys them by the recurrence id alone. Two series that meet at the same
|
||||
* time would therefore take over each other's modifications.
|
||||
*
|
||||
* @param {ICAL.Component[]} components The VEVENTs of the calendar
|
||||
* @returns {Map<string, ICAL.Component[]>} The exceptions per UID
|
||||
*/
|
||||
function exceptionsByUid(components) {
|
||||
const exceptions = new Map();
|
||||
|
||||
for (const component of components) {
|
||||
if (!component.hasProperty("recurrence-id")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uid = component.getFirstPropertyValue("uid");
|
||||
const ofEvent = exceptions.get(uid);
|
||||
|
||||
if (ofEvent) {
|
||||
ofEvent.push(component);
|
||||
} else {
|
||||
exceptions.set(uid, [component]);
|
||||
}
|
||||
}
|
||||
|
||||
return exceptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the recurrences of an event have to be iterated.
|
||||
*
|
||||
* The iteration walks the unmodified recurrence times, so an occurrence that
|
||||
* was moved to an earlier time is only reached through the time it originally
|
||||
* had, which can lie past the end of the window. Keep going for as long as the
|
||||
* largest move towards the past can still carry an occurrence into it.
|
||||
*
|
||||
* @param {ICAL.Event} event The event whose recurrences are iterated
|
||||
* @param {Date} to End of the window
|
||||
* @returns {Date} The recurrence time to stop at
|
||||
*/
|
||||
function iterationEnd(event, to) {
|
||||
let last = to.getTime();
|
||||
|
||||
for (const exception of Object.values(event.exceptions)) {
|
||||
const movedBy = exception.recurrenceId.toJSDate().getTime()
|
||||
- exception.startDate.toJSDate().getTime();
|
||||
|
||||
if (movedBy > 0) {
|
||||
last = Math.max(last, to.getTime() + movedBy);
|
||||
}
|
||||
}
|
||||
|
||||
return new Date(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an occurrence touches a window.
|
||||
*
|
||||
* @param {ICAL.Time} startDate Start of the occurrence
|
||||
* @param {ICAL.Time} endDate End of the occurrence
|
||||
* @param {Date} from Start of the window
|
||||
* @param {Date} to End of the window
|
||||
* @returns {boolean} True when the two overlap
|
||||
*/
|
||||
function touches(startDate, endDate, from, to) {
|
||||
return startDate.toJSDate() <= to && endDate.toJSDate() > from;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the calendar of the club.
|
||||
*
|
||||
* @returns {Promise<ICAL.Component>} The calendar
|
||||
*/
|
||||
export async function loadCalendar() {
|
||||
const response = await fetch(icsUrl);
|
||||
|
||||
// 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 new ICAL.Component(ICAL.parse(await response.text()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of the calendar that touches the given window.
|
||||
*
|
||||
* A recurring event is expanded, and an occurrence that was modified on its own
|
||||
* is reported with the time, the name and the URL of that modification.
|
||||
*
|
||||
* @param {ICAL.Component} calendar The parsed calendar
|
||||
* @param {Date} from An occurrence has to still be running at this time
|
||||
* @param {Date} to An occurrence has to have started by this time
|
||||
* @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}}
|
||||
*/
|
||||
export function* occurrencesBetween(calendar, from, to) {
|
||||
const components = calendar.getAllSubcomponents("vevent");
|
||||
const exceptions = exceptionsByUid(components);
|
||||
|
||||
for (const component of components) {
|
||||
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
||||
// belong to, handling them here as well would report them twice.
|
||||
if (component.hasProperty("recurrence-id")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = new ICAL.Event(component, {
|
||||
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
|
||||
});
|
||||
|
||||
if (!event.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!event.isRecurring()) {
|
||||
if (touches(event.startDate, event.endDate, from, to)) {
|
||||
yield { event, startDate: event.startDate, endDate: event.endDate };
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = event.iterator();
|
||||
const iterateUntil = iterationEnd(event, to);
|
||||
|
||||
while (true) {
|
||||
const occurrence = iterator.next();
|
||||
|
||||
// Recurrences are chronological, so we are done once one starts after the
|
||||
// window, and after the occurrences a modification can still move back
|
||||
// into it.
|
||||
if (!occurrence || occurrence.toJSDate() > iterateUntil) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Details resolve time, name and URL of an occurrence that was modified
|
||||
// via RECURRENCE-ID.
|
||||
const details = event.getOccurrenceDetails(occurrence);
|
||||
|
||||
// A modification may have moved the occurrence out of the window, so
|
||||
// judge it by the time it really takes place at.
|
||||
if (touches(details.startDate, details.endDate, from, to)) {
|
||||
yield {
|
||||
event: details.item,
|
||||
startDate: details.startDate,
|
||||
endDate: details.endDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||
|
||||
/**
|
||||
* When an occurrence starts, as a point in time.
|
||||
*
|
||||
* A date has no time and no zone, its digits are the day itself. toJSDate()
|
||||
* reads them as midnight in the zone of the browser, which moves an all day
|
||||
* event by the offset that zone has to Berlin and, far enough east or west,
|
||||
* onto the day before or after. Keep the digits and read them as UTC instead,
|
||||
* the table prints an all day event in UTC as well.
|
||||
*
|
||||
* @param {ICAL.Time} time Start of the occurrence
|
||||
* @returns {Date} The point in time to sort and print by
|
||||
*/
|
||||
function startOf(time) {
|
||||
if (time.isDate) {
|
||||
return new Date(Date.UTC(time.year, time.month - 1, time.day));
|
||||
}
|
||||
|
||||
return time.toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming occurrences of a calendar.
|
||||
*
|
||||
* @param {ICAL.Component} calendar The parsed calendar
|
||||
* @param {Date} now Events must still be running at this date
|
||||
* @param {number} maxEvents Maximum number of events to return
|
||||
* @param {number} maxDays Maximum number of days into the future
|
||||
* @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL
|
||||
*/
|
||||
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
|
||||
const end = new Date(now.getTime());
|
||||
end.setDate(end.getDate() + maxDays);
|
||||
|
||||
const events = [];
|
||||
|
||||
// A running event stays listed until it is over, so the window starts at now
|
||||
// and the walk keeps everything that has not ended yet.
|
||||
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
|
||||
events.push({
|
||||
start: startOf(startDate),
|
||||
allDay: startDate.isDate,
|
||||
name: event.summary ?? "",
|
||||
url: eventUrl(event),
|
||||
});
|
||||
}
|
||||
|
||||
// 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 max_days = 20;
|
||||
const max_items = 5;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const table = document.getElementById("upcoming");
|
||||
loadCalendar()
|
||||
.then(calendar => {
|
||||
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const colBegin = document.createElement("td");
|
||||
|
||||
// The events take place in Berlin, so name their time in Berlin time
|
||||
// instead of in the time zone the visitor happens to be in. An all day
|
||||
// event has no time of day and carries its date in UTC, see startOf().
|
||||
const whenFormat = event.allDay
|
||||
? { timeZone: "UTC" }
|
||||
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
|
||||
|
||||
const formattedStart = event.start.toLocaleString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
...whenFormat,
|
||||
});
|
||||
|
||||
colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`;
|
||||
row.appendChild(colBegin);
|
||||
|
||||
const colName = document.createElement("td");
|
||||
|
||||
if (event.url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = event.url;
|
||||
a.text = event.name;
|
||||
|
||||
colName.appendChild(a);
|
||||
} else {
|
||||
colName.innerText = event.name;
|
||||
}
|
||||
|
||||
row.appendChild(colName);
|
||||
|
||||
table.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(err => console.error("Fehler beim Laden der Termine:", err));
|
||||
});
|
||||
19
assets/js/vendor/README.md
vendored
19
assets/js/vendor/README.md
vendored
|
|
@ -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
9732
assets/js/vendor/ical.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 365 KiB |
|
|
@ -1,66 +0,0 @@
|
|||
---
|
||||
title: "Linke und Grüne ziehen gegen das Berliner Polizeigesetz vor Gericht"
|
||||
date: 2026-08-28T12:00:00+02:00
|
||||
showHero: false
|
||||
tags: ["Überwachung", "Polizeigesetz", "ASOG"]
|
||||
---
|
||||
|
||||
{{< figure
|
||||
src="feature-ki-kamera.jpg"
|
||||
alt="KI-Überwachungskamera"
|
||||
caption="KI-Überwachungskamera. Foto: netzpolitik.org, CC BY-NC-SA 4.0"
|
||||
>}}
|
||||
|
||||
Als CDU und SPD Ende 2025 das Berliner Polizeigesetz verschärften, war die Kritik laut. Jetzt wird sie juristisch: Am 28. August 2026 haben Bündnis 90/Die Grünen und Die Linke beim Verfassungsgerichtshof des Landes Berlin einen Antrag auf Normenkontrolle eingereicht. Das höchste Berliner Gericht soll nun prüfen, ob die neuen Überwachungsbefugnisse mit der Landesverfassung vereinbar sind oder schlicht zu weit gehen.
|
||||
|
||||
### Vor fünf Tagen war das noch ein Thema im Club
|
||||
|
||||
Am 23. August fand unser [Aktionstag gegen Überwachung](/post/2026/08/11/aktionstag-gegen-überwachung/) statt. Rund 80 Leute kamen, um über KI-Kameras, Verhaltensscanner und das verschärfte Polizeigesetz zu reden. Auch Abgeordnete der Linksfraktion waren dabei und erzählten dort schon, dass sie gegen das Gesetz vorgehen wollen. Ein Normenkontrollantrag war im Gespräch.
|
||||
|
||||
Jetzt liegt er vor.
|
||||
|
||||
### Kurzer Rückblick
|
||||
|
||||
Im Dezember 2025 beschloss die schwarz-rote Koalition eine Novelle des Allgemeinen Sicherheits- und Ordnungsgesetzes (ASOG). Heraus kam ein Gesetzespaket voller neuer digitaler Befugnisse: von Kameras, die Verhalten automatisch analysieren, bis zu einer zentralen Plattform, auf der ganz unterschiedliche Polizeidaten zusammengeführt werden dürfen.
|
||||
|
||||
Die Berliner Datenschutzbeauftragte warnte, die Opposition warnte, unabhängige Sachverständige im Innenausschuss warnten. Passiert ist trotzdem wenig: die Kritik wurde im Verfahren nicht ausgeräumt. Genau hier setzt der Normenkontrollantrag an.
|
||||
|
||||
### Worum es konkret geht
|
||||
|
||||
Die Kanzlei KM8, die die Fraktionen vertritt, zählt in ihrer Begründung fünf Punkte auf, die besonders stechen:
|
||||
|
||||
1. Die Polizei darf künftig mit Spähsoftware auf Smartphones und Computer zugreifen und zwar nicht nur bei schwersten Straftaten, sondern schon zur Verhinderung vergleichsweise kleiner Delikte. Wer solche Software baut, braucht Sicherheitslücken. Das Gesetz setzt also einen Anreiz, Lücken offenzuhalten, statt sie zu stopfen.
|
||||
|
||||
2. Der überwachte öffentliche Raum soll automatisiert ausgewertet werden: Wer sich „auffällig" verhält, löst Alarm aus. Was auffällig ist, entscheidet der Algorithmus - ohne klare Einsatzregeln und ohne echten Schutz gegen Fehler, Diskriminierung oder Missbrauch.
|
||||
|
||||
3. Öffentlich zugängliche Bilder aus dem Internet darf die Polizei in großem Stil biometrisch auswerten. Die Kanzlei spricht von biometrischer Massenüberwachung und von einem Abschreckungseffekt, der vor allem diejenigen trifft, die demonstrieren wollen.
|
||||
|
||||
4. Polizeilich erhobene Daten sollen für einen komplett neuen Zweck weiterverwendet werden dürfen: das Training von KI-Systemen, teilweise sogar unter Weitergabe an private Firmen. Was genau mit welchen Daten passiert, lässt das Gesetz offen. Der Grundsatz, dass Daten nur für den Zweck genutzt werden, für den sie erhoben wurden, wird damit faktisch ausgehebelt.
|
||||
|
||||
5. Verschiedenste Bestände dürfen auf einer zentralen Analyseplattform zusammengeführt und automatisiert ausgewertet werden auch mit KI. Übrig bleibt eine Art Riesendatenbank, in der Daten aus völlig unterschiedlichen Zusammenhängen für neue Zwecke zur Verfügung stehen. Ausgeschlossen ist dabei auch der Einsatz von Palantir nicht.
|
||||
|
||||
### Was eine Normenkontrolle überhaupt ist
|
||||
|
||||
Anders als bei einer konkreten Klage geht es bei der abstrakten Normenkontrolle nicht um einen einzelnen Fall. Das Gericht schaut sich das Gesetz als solches an und prüft, ob es mit der Verfassung vereinbar ist. Antragsberechtigt ist unter anderem ein Viertel der Abgeordneten genau dieses Quorum bringen Die Linke und die Grünen zusammen. Hält das Gericht einzelne Regelungen für verfassungswidrig, erklärt es sie für nichtig.
|
||||
|
||||
### Was die Beteiligten sagen
|
||||
|
||||
Die innenpolitischen Sprecher Vasili Franco (Grüne) und Niklas Schrader (Die Linke) nennen die Novelle einen massiven Ausbau der Überwachung. Zusammen mit der automatisierten Auswertung von Polizeidaten könnte eine „riesige Superdatenbank" entstehen, die „jegliche Grenzen des Rechtsstaates sprengt". Und sie kritisieren: Es sei völlig unklar, welche Daten künftig wofür verarbeitet werden dürfen. Für ein Parlament, das die Polizei kontrollieren soll, sei das nicht hinnehmbar.
|
||||
|
||||
Ihr Kernargument: Polizeigesetze sind keine gewöhnlichen Gesetze. Sie entscheiden, was die Polizei in einem Rechtsstaat darf und das Gewaltmonopol des Staates muss sich an den Grundrechten messen lassen. „Verfassungswidrige Polizeigesetze sind keine guten Polizeigesetze." Deshalb halten sie eine verfassungsrechtliche Prüfung für unabdingbar.
|
||||
|
||||
Rechtsanwalt David Werdermann, der die Fraktionen vertritt, bringt es ähnlich auf den Punkt: Die Novelle bedeute einen grundlegenden Kurswechsel, mit Befugnissen, die aus mehreren Gründen gegen Grundrechte verstoßen.
|
||||
|
||||
### Warum das jetzt wichtig ist
|
||||
|
||||
Der Ball liegt beim Verfassungsgerichtshof. Dessen Entscheidung wird nicht nur über die einzelnen Punkte entscheiden, sondern grundsätzlich darüber, wie viel Überwachung die Landesverfassung überhaupt hergibt – vom Verhaltensscanner im öffentlichen Raum bis zur zentralen Datenanalyse.
|
||||
|
||||
Wie das ausgeht, ist offen. Sicher ist nur: Der Streit um das Polizeigesetz wird nicht mehr allein politisch geführt, sondern jetzt auch vor Gericht.
|
||||
|
||||
### Zum Weiterlesen
|
||||
|
||||
- Linksfraktion Berlin: [Linke und Grüne erheben Normenkontrolle gegen verfassungswidrige Regelungen im schwarz-roten Polizeigesetz](https://www.linksfraktion.berlin/politik/presse/detail/news/linke-und-gruene-erheben-normenkontrolle-gegen-verfassungswidrige-regelungen-im-schwarz-roten-polizeigesetz/)
|
||||
- Verfassungsblog: [„Try harder hilft selten": die ASOG-Novelle verfassungsrechtlich eingeordnet](https://verfassungsblog.de/berlin-asog-novelle-kbos/)
|
||||
- taz: [KI-Videoüberwachung in Berlin: Verhaltensscanner bald auch vor dem Abgeordnetenhaus](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/)
|
||||
- netzpolitik.org: [So wehrt man sich gegen Verhaltensscanner](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/)
|
||||
- beck-aktuell: [Kameras und KI-Einsatz: Berlin verschärft Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
|
|
@ -1,85 +0,0 @@
|
|||
---
|
||||
title: "Peter Thiel ante portas"
|
||||
date: 2026-08-28T12:00:00+02:00
|
||||
showHero: false
|
||||
tags: ["Überwachung", "Palantir", "Ankündigung"]
|
||||
---
|
||||
|
||||
{{< figure
|
||||
src="feature-peter-thiel.jpg"
|
||||
alt="Peter Thiel"
|
||||
caption="Peter Thiel. Foto: Gage Skidmore, CC BY-SA 2.0"
|
||||
>}}
|
||||
|
||||
### Was?
|
||||
|
||||
Am 24. September nimmt Peter Thiel in Berlin den Axel-Springer-Award entgegen. Drei Tage vorher, am 21. September, schauen wir uns im Club an, wer da eigentlich geehrt wird. Denn der Mann, den Axel Springer da auszeichnet, ist kein harmloser Tech-Unternehmer.
|
||||
|
||||
Peter Thiel hat PayPal mitgegründet, früh in Facebook investiert und mit Palantir eines der wichtigsten Überwachungsunternehmen der Welt aufgebaut. Er ist einer der reichsten und einflussreichsten Männer des Silicon Valley, und seine Vorstellungen davon, wie die Welt aussehen soll, sind radikal. Darüber wollen wir reden.
|
||||
|
||||
### Wer ist Peter Thiel?
|
||||
|
||||
Peter Thiel wurde 1967 in Frankfurt am Main geboren und wanderte als Kind mit seiner Familie in die USA aus. Er studierte Philosophie in Stanford, machte einen Abschluss in Jura und arbeitete kurz als Anwalt, bevor er in die Tech-Welt wechselte. 1998 gründete er mit Max Levchin das Unternehmen Confinity, aus dem später PayPal wurde. Als eBay PayPal 2002 für 1,5 Milliarden Dollar kaufte, war Thiel als Vorstandschef der große Gewinner.
|
||||
|
||||
Danach wurde er zu einer der zentralen Figuren des Silicon Valley. 2004 gab er als erster externer Investor eine halbe Million Dollar in ein damals unbekanntes Studentenprojekt namens Facebook. Mit seiner Wagniskapitalfirma Founders Fund steckte er früh Geld in Unternehmen wie SpaceX, Airbnb und die Rüstungsfirma Anduril. Und mit dem Thiel Fellowship bezahlt er seit 2011 junge Menschen dafür, ihr Studium abzubrechen und stattdessen Firmen zu gründen.
|
||||
|
||||
Vor allem aber steht sein Name für Palantir.
|
||||
|
||||
### Palantir
|
||||
|
||||
Palantir ist nach den Sehsteinen aus Tolkiens „Herr der Ringe" benannt, den Palantíri. Das passt, denn die Firma baut genau das: Software, die alles sehen soll. Ihre Werkzeuge durchforsten riesige Datenmengen nach Mustern, Verbindungen und Auffälligkeiten.
|
||||
|
||||
Die Kundschaft sind Geheimdienste, Militärs und Polizeibehörden. Palantir-Software half der US-Einwanderungsbehörde ICE dabei, Abschiebungen zu organisieren. Sie unterstützt Militärs bei der Auswahl von Zielen und steckt hinter polizeilichen Analyse- und Vorhersagesystemen. In Großbritannien hat sich Palantir einen umstrittenen Vertrag über die Gesundheitsdaten des staatlichen NHS gesichert.
|
||||
|
||||
Auch bei uns wird darum gestritten. Erst kürzlich haben wir [darüber geschrieben]({{< ref "post/2026-08-28-normenkontrolle-asog" >}}), dass im neuen Berliner Polizeigesetz der Einsatz von Palantir ausdrücklich nicht ausgeschlossen ist. Die zentrale Analyseplattform, die dieses Gesetz vorsieht, ist genau das Geschäftsmodell von Palantir.
|
||||
|
||||
### Thiels Weltbild
|
||||
|
||||
Thiel ist nicht nur Geschäftsmann. Er hat eine klare politische Agenda, und die ist erklärtermaßen antidemokratisch. 2009 schrieb er in einem Essay: „I no longer believe that freedom and democracy are compatible." Zu Deutsch: Ich glaube nicht mehr daran, dass Freiheit und Demokratie vereinbar sind. Im selben Text machte er das Wahlrecht für Frauen und den Sozialstaat dafür verantwortlich, dass die liberale Demokratie seiner Ansicht nach gescheitert ist.
|
||||
|
||||
Er steht dem Umfeld der sogenannten Neoreaktion nahe, einer Denkrichtung, die die Demokratie für gescheitert hält und stattdessen autoritäre Strukturen anstrebt. Ein Vordenker dieser Szene, Curtis Yarvin, träumt öffentlich von der Abschaffung des Parlamentarismus und von einem Staat, der wie ein Unternehmen geführt wird, mit einem Chef an der Spitze statt gewählter Vertreter:innen. Thiel hat Yarvin über Jahre gefördert und gepriesen.
|
||||
|
||||
Thiels Geld fließt auch in die Politik. Er unterstützte Donald Trump bereits 2016 und finanzierte 2022 mit 15 Millionen Dollar die Senatskampagne von JD Vance, dem heutigen Vizepräsidenten der USA. Es war damals die größte Einzelspende an einen Senatskandidaten. Und mit dem Seasteading Institute wollte er schwimmende Stadtstaaten errichten, die sich staatlicher Regeln komplett entziehen.
|
||||
|
||||
Kurz gesagt: Hier geht es um jemanden, der die Demokratie nicht nur kritisiert, sondern sie abschaffen will.
|
||||
|
||||
### Thiels Glaube und der Antichrist
|
||||
|
||||
Was viele bei Thiel übersehen: Seine Weltsicht ist tief religiös geprägt, allerdings auf eine sehr eigene Art. Thiel ist gläubiger Christ, aber weit entfernt vom Mainstream. Sein Denken ist stark von dem französischen Philosophen René Girard beeinflusst, dessen Theorie um Nachahmung, Sündenböcke und Apokalypse kreist. Thiel hat Girard über Jahre studiert und dessen apokalyptische Grundhaltung übernommen.
|
||||
|
||||
Das zeigt sich besonders bei einem Thema, über das Thiel immer wieder spricht: den Antichristen. Thiel nimmt das wörtlich. In Interviews beschreibt er den Antichristen als eine Figur, die einen weltweiten Einheitsstaat errichtet, einen falschen Frieden verspricht und dabei jede Freiheit abschafft. Zentrale Datenmacht, allgegenwärtige Überwachung und Dinge wie das chinesische Social-Credit-System ordnet er in dieses Endzeitszenario ein. Er beschreibt sich selbst als jemand, der die Zeichen dafür überall erkennt.
|
||||
|
||||
An dieser Stelle lohnt es sich, kurz innezuhalten. Denn hier steckt ein bemerkenswerter Widerspruch: Thiel warnt vor einem totalitären Überwachungsstaat als Vorboten des Antichristen, während er gleichzeitig mit Palantir das leistungsfähigste Überwachungsunternehmen der Welt aufbaut und an genau die Staaten verkauft, vor denen er angeblich warnt. Ob man das nun für aufrichtige Angst, für Zynismus oder für beides hält: Es sagt eine Menge über diesen Mann aus, dass sein Geschäft genau das herstellt, was sein Glaube verdammt.
|
||||
|
||||
### Der Axel-Springer-Award
|
||||
|
||||
Vor diesem Hintergrund ist es bemerkenswert, dass ausgerechnet Axel Springer diesen Mann auszeichnet. Axel Springer ist einer der größten Medienkonzerte Europas und gibt Blätter wie Bild und Welt heraus. Den Axel-Springer-Award verleiht der Konzern seit Jahren an Persönlichkeiten der Digitalwirtschaft, unter den bisherigen Preisträgern waren Mark Zuckerberg, Jeff Bezos und Elon Musk.
|
||||
|
||||
Mit Peter Thiel ehrt Axel Springer nun einen Mann, der offen erklärt hat, dass er Demokratie und Freiheit für unvereinbar hält. Die Verleihung am 24. September in Berlin ist eine Bühne. Wir finden: Sie sollte nicht unwidersprochen bleiben. Deshalb laden wir drei Tage vorher in den Club ein.
|
||||
|
||||
### Programm
|
||||
|
||||
**19:00 Uhr: Vortrag** \
|
||||
Wer ist Peter Thiel? Was macht Palantir? Welche Netzwerke, welches Weltbild und welcher Glaube stecken dahinter? Und warum ist das alles gefährlich?
|
||||
|
||||
**Anschließend: Austausch** \
|
||||
Diskussion und offener Austausch, offenes Ende.
|
||||
|
||||
Ihr braucht kein Vorwissen. Kommt einfach vorbei.
|
||||
|
||||
### Wo?
|
||||
|
||||
📍 **Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin**
|
||||
|
||||
🚈 Etwa 5 Minuten ab dem S-Bahnhof Friedrichstraße
|
||||
|
||||
### Wann?
|
||||
|
||||
**📆 Montag, 21. September 2026** \
|
||||
**ab 19:00 Uhr**
|
||||
|
||||
### Zum Weiterlesen
|
||||
|
||||
- Wikipedia: [Peter Thiel](https://de.wikipedia.org/wiki/Peter_Thiel)
|
||||
- Wikipedia: [Palantir Technologies](https://de.wikipedia.org/wiki/Palantir_Technologies)
|
||||
- Wikipedia: [Axel Springer SE](https://de.wikipedia.org/wiki/Axel_Springer_SE)
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
title: "Peter Thiel ante portas"
|
||||
subtitle: "Vortrag und Austausch über Peter Thiel, Palantir und den Axel-Springer-Preis"
|
||||
date: 2026-08-28T12:00:00+02:00
|
||||
dtstart: 20260921T190000
|
||||
dtend: 20260921T220000
|
||||
menu:
|
||||
main:
|
||||
parent: "Veranstaltungen"
|
||||
tag: ["Veranstaltung"]
|
||||
---
|
||||
|
||||
**Am 21. September schauen wir uns an, wer da am 24. September in Berlin ausgezeichnet wird.**
|
||||
|
||||
Peter Thiel kommt nach Berlin, um den Axel-Springer-Award entgegenzunehmen. Er ist Mitgründer von PayPal und Palantir, früher Investor bei Facebook und einer der einflussreichsten Geldgeber der Tech-Rechten. Grund genug, sich ihn genauer anzusehen.
|
||||
|
||||
Das Programm:
|
||||
|
||||
- **19:00 Uhr:** Vortrag zu Peter Thiel, Palantir und seinen Netzwerken
|
||||
- **anschließend:** Offener Austausch und Diskussion, offenes Ende
|
||||
|
||||
📍 Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
|
||||
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
|
||||
{{ $js := resources.Get "js/calendar.js" }}
|
||||
{{ $css := resources.Get "css/calendar.css" }}
|
||||
|
||||
<div class="calendar-container">
|
||||
{{ with $css }}
|
||||
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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
113
static/js/upcoming.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue