commit d7d86601f048c66cda9cadb276b635247aac917e Author: Stefan Bühler Date: Mon Dec 16 20:56:04 2024 +0100 initial development version diff --git a/README.md b/README.md new file mode 100644 index 0000000..da0d66d --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Show upcoming appointments from church.tools instance + +## Setup + +* Upload `css/ct-events.css` and `js/ct-events.js` to your site +* Download `mithril.min.js` from https://unpkg.com/browse/mithril@2.2.11/ (or a newer version, but tested with this) +* Upload `mithril.min.js` to the same folder as `ct-events.js` +* Download `fontawesome-free-6.7.1-web.zip` from https://fontawesome.com/download ("Free for Web"), tested with version 6.7.1 +* Extract and upload `css/all.min.css` and `webfonts/fa-solid-900*`; the CSS expects the font to be in `../webfonts/`, so keep the folder hierarchy. +* Add the following to your site template header (adapt paths to your local setup), i.e. in `...`: + +```html + + + + +``` + +* Add the following to your site template footer (adapt paths to your local setup), i.e. at the end of `...`: + +```html + +``` + +## Allow church-tools API CORS from your site + +Add your site (`https://your-site`) to `Access-Control-Allow-Origins` sent by your church-tool instance, see: + +## Add calendar events + +Add this snippet to your page: + +```html +
+``` + +The class `cte-events` triggers the javascript; options can be overridden and set via `data-cte-*` attributes. + +You can add multiple snippets on a page with different settings. diff --git a/css/ct-events.css b/css/ct-events.css new file mode 100644 index 0000000..4ce67a1 --- /dev/null +++ b/css/ct-events.css @@ -0,0 +1,168 @@ +:root { + --cte-bg-color: #f9e3c8; + --cte-primary-color: #557e76; + --cte-header-color: #525f7f; +} + +.cte-events { + background-color: var(--cte-bg-color); + font-family: roboto, sans-serif; + margin: 30px auto; + font-weight: 300; + line-height: 1.5; +} + +.cte-events .cte-event { + width: 100%; + margin-bottom: 3em; +} +.cte-events /* .cte-event */ .cte-summary { + display: grid; + gap: 1.5rem; + padding-right: 1.5rem; + grid-template-columns: 1fr 4fr; + grid-template-rows: auto; + grid-template-areas: "image info"; + + /* position / z-index needed to have box-shadow over details collapsible */ + position: relative; + z-index: 2; + box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15); + word-wrap: break-word; + background-color: #fff; + background-clip: border-box; + border-radius: .375rem; + + overflow: hidden; +} +.cte-events /* .cte-event .cte-summary */ .cte-image { + grid-area: image + width: 100%; +} +.cte-events /* .cte-event .cte-summary */ .cte-image img { + width: 100%; + height: 100%; + object-fit: cover; + vertical-align: middle; + border-style: none; +} +.cte-events /* .cte-event .cte-summary */ .cte-info { + grid-area: info +} +.cte-events /* .cte-event .cte-summary .cte-info */ .cte-info-header { + color: var(--cte-header-color); + fill: var(--cte-header-color); + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + margin-bottom: .25rem; + gap: .5rem; + font-weight: 300; +} +.cte-events /* .cte-event .cte-summary .cte-info .cte-info-header */ .cte-date { +} +.cte-events /* .cte-event .cte-summary .cte-info .cte-info-header */ .cte-download {} +.cte-events /* .cte-event .cte-summary .cte-info .cte-info-header */ .cte-download a { + text-decoration: none; + color: inherit; +} +.cte-events /* .cte-event .cte-summary .cte-info */ .cte-description { + color: var(--cte-primary-color); +} + +.cte-events /* .cte-event .cte-summary .cte-info */ .cte-description :is(h1, h2) { + margin: 0; + font-weight: 300; + line-height: 1.5; +} + +.cte-events /* .cte-event .cte-summary .cte-info */ .cte-description h2 { + font-size: 1.0625rem; +} + +.cte-events .cte-icon { + width: 1em; + height: 1em; + vertical-align: -0.125em; +} + +.cte-events /* .cte-event */ .cte-details-collapser { + margin: 0 1rem; + background: #fff; +} + +.cte-events /* .cte-event */ .cte-details-collapser.collapsed { + background: inherit; +} + +.cte-events /* .cte-event */ .cte-details-collapser.collapsed .cte-details { + display: none; +} + +.cte-events /* .cte-event */ .cte-details-collapser .toggle-collapse { + display: block; + margin: auto; +} + +.cte-events /* .cte-event */ .cte-details-collapser .toggle-collapse { + height: 1.875rem; + width: 13.25rem; + color: var(--cte-primary-color); + fill: var(--cte-primary-color); + border-color: var(--cte-primary-color); + background-color: var(--cte-bg-color); + text-transform: none; + letter-spacing: .025em; + font-size: .875rem; + gap: 0.5rem; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: .375rem; +} + +.cte-events /* .cte-event */ .cte-details-collapser.collapsed .toggle-collapse { + bottom: -1.775rem; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.cte-events /* .cte-event */ .cte-details-collapser:not(.collapsed) .toggle-collapse { + bottom: 0; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +.cte-events /* .cte-event */ .cte-details-collapser .toggle-collapse:hover { + background-color: var(--cte-primary-color); + color: #fff; + fill: #fff; + border-color: var(--cte-primary-color); +} + +.cte-events /* .cte-event .cte-details-collapser */ .cte-details { + padding: 1.5rem; + padding-bottom: 0; +} + +.cte-events /* .cte-event .cte-details-collapser */ .cte-details .cte-information-group { + display: flex; + flex-direction: row; + align-items: first baseline; + flex-wrap: wrap; + gap: 1.5rem; +} + +.cte-events /* .cte-event .cte-details-collapser */ .cte-details :is(h1, h2, h3) { + color: var(--cte-primary-color); + margin: 0; + font-weight: 300; + line-height: 1.5; +} +.cte-events /* .cte-event .cte-details-collapser */ .cte-details h2 { + font-size: 1.1rem; + margin-top: .8rem; + font-weight: bolder; +} diff --git a/example.html b/example.html new file mode 100644 index 0000000..274ec50 --- /dev/null +++ b/example.html @@ -0,0 +1,28 @@ + + + + + + + + + +
+
+ + + diff --git a/js/ct-events.js b/js/ct-events.js new file mode 100644 index 0000000..e013b44 --- /dev/null +++ b/js/ct-events.js @@ -0,0 +1,425 @@ +import "./mithril.min.js"; + +function fa_solid_icon(settings, name) { + if (settings.fontawesome_svg_path) { + // modify upstream svg to start with `` + return m("svg.cte-icon", m("use", {href: `${settings.fontawesome_svg_path}/${name}-solid.svg#root`})); + } else { + return m(`i.fa-solid.fa-${name}.cte-icon`) + } +} + +const is_mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + +var AppointmentImage = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + const image_url = item.base.image?.fileUrl ?? (is_mobile ? default_image_mobile : null) ?? settings.default_image; + + return m(".cte-image", + image_url ? m( + "img", + { + src: image_url, + alt: item.base.caption, + }, + ) : null, + ) + } +} + +// Options for date and time formatting +const fmtoptions_date = { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Berlin' }; +const fmtoptions_date_short = { day: 'numeric', month: 'numeric', year: 'numeric', timeZone: 'Europe/Berlin' }; +const fmtoptions_date_utc = { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }; +const fmtoptions_time = { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' }; +const fmt_date = new Intl.DateTimeFormat('de-DE', fmtoptions_date); +const fmt_date_short = new Intl.DateTimeFormat('de-DE', fmtoptions_date_short); +const fmt_date_utc = new Intl.DateTimeFormat('de-DE', fmtoptions_date_utc); +const fmt_time = new Intl.DateTimeFormat('de-DE', fmtoptions_time); + +function display_date_utc(date, content) { + const repr = date.toISOString().split("T")[0]; + return m("time", {datetime: repr}, content); +} + +function display_date_local(date, content) { + const [d, m, y] = fmtoptions_date_short.format(date).split("."); + const repr = [y, m, d].join("-"); + return m("time", {datetime: repr}, content); +} + +function display_datetime(date, content) { + const repr = date.toISOString(); + return m("time", {datetime: repr}, content); +} + +function display_event_date(item) { + const start = new Date(item.calculated.startDate); + const end = new Date(item.calculated.endDate); + + const start_date = fmt_date.format(start); + const end_date = fmt_date.format(end); + const start_time = fmt_time.format(start); + const end_time = fmt_time.format(end); + + if (start === end) { + return display_datetime(start, `${start_date}, ${start_time}`); + } + + if (start_time === "00:00" && end_time === "00:00") { + // localtime "all-day" event + const end_day = new Date(end.valueOf() - 86400); + const end_day_date = fmt_date.format(end_day); + if (start_date === end_day_date) { + return display_date_local(start, start_date); + } else { + return m("span.timerange", display_date_local(start, start_date), " - ", display_date_local(end_day, end_day_date)); + } + } + + if (start.getUTCHours() === 0 && start.getUTCMinutes() === 0 && end.getUTCHours() === 0 && end.getUTCMinutes() === 0) { + // UTC "all-day" event + const end_day = new Date(end.valueOf() - 86400); + const utc_start_date = fmt_date_utc.format(start); + const utc_end_day_date = fmt_date_utc.format(end_day); + if (utc_start_date === utc_end_day_date) { + return display_date_utc(start, utc_start_date); + } else { + return m("span.timerange", display_date_utc(start, utc_start_date), " - ", display_date_utc(end_day, utc_end_day_date)); + } + } + + if (start_date === end_date) { + return m("span.timerange", display_datetime(start, `${start_date}, ${start_time}`), " - ", display_datetime(end, end_time)); + } else { + return m( + "span.timerange", + display_datetime(start, `${start_date}, ${start_time}`), + " - ", + display_datetime(end, `${end_date}, ${end_time}`) + ); + } +} + +function escapeICSField(text) { + return text.replace(/\\/g, "\\\\") + .replace(/[,;]/g, "\\$&") + .replace(/\n/g, "\\n"); +} + +function createICSLink(item, settings) { + const start = new Date(item.calculated.startDate).toISOString().replace(/-|:|\.\d+/g, ''); + const end = new Date(item.calculated.endDate).toISOString().replace(/-|:|\.\d+/g, ''); + + const summary = item.base.caption; + const description = item.base.information || ""; + const location = item.base.address?.meetingAt || ""; + + // Escape special characters for ICS fields + const escapedSummary = escapeICSField(summary /* .replace(/['"]/g, "") */); + const escapedDescription = escapeICSField(description /* .replace(/['"]/g, "") */); + const escapedLocation = escapeICSField(location); + + const icsContent = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Evang Kirchengemeinde Malmsheim//Gottesdienste//EN +BEGIN:VEVENT +UID:${crypto.randomUUID()}@malmsheim-evangelisch.de +DTSTAMP:${new Date().toISOString().replace(/-|:|\.\d+/g, '')} +DTSTART:${start} +DTEND:${end} +SUMMARY:${escapedSummary} +DESCRIPTION:${escapedDescription} +LOCATION:${escapedLocation} +END:VEVENT +END:VCALENDAR +`; + + const blob = new Blob([icsContent], { type: 'text/calendar' }); + const url = URL.createObjectURL(blob); + + return m( + "a", + { + href: url, + download: "event.ics", + }, + [ + fa_solid_icon(settings, "calendar-plus"), + is_mobile ? "" : " Termin herunterladen", + ] + ) +} + +var AppointmentHeader = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + + return m(".cte-info-header", [ + m("span.cte-date", display_event_date(item)), + m("span.cte-download", createICSLink(item, settings)), + ]); + } +} + +var AppointmentDescription = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + const note_parts = []; + if (item.base.note) { + note_parts.push(item.base.note); + } + if (item.base.address?.meetingAt) { + note_parts.push(item.base.address.meetingAt); + } + return m(".cte-description", [ + m("h1", item.base.caption), + m("h2", note_parts.join(" | ")), + ]); + } +} + +/* find `http://`, `https://` and `www.` links + * - requires whitespace, parantheses `(, `)` or line start/end before and after it + * - find non-links as first capture, links as second capture (might be empty) + */ +const REGEX_FIND_LINKS = /(?<=^|[\s()])(?:https?:\/\/|www\.)[^\s()*]*(?=[()\s]|$)/; +const REGEX_FIND_LINKS_CONT = /(?<=[\s()])(?:https?:\/\/|www\.)[^\s()*]*(?=[()\s]|$)/; + +function* format_links(part) { + let regex = REGEX_FIND_LINKS; + while (true) { + const match = part.match(regex); + regex = REGEX_FIND_LINKS_CONT; + const normal = part.substring(0, match?.index ?? part.length); + yield normal; + if (!match) { + return; + } + part = part.substring(match.index + match[0].length); + const url = match[0]; + yield m("a", {href: url, target: "_blank"}, url); + } +} + +/* find `*...*` highlighted text: + * - requires whitespace or line start/end before and after it + * - requires non-space on the inside as first and last character + * - `*` not allowed inside + * - find non-highlighted text as first capture, highlighted as second capture (might be empty) + */ +const REGEX_FIND_HIGHLIGHTED = /(?<=^|\s)\*[^*\s](?:[^*]*[^*\s])?\*(?=\s|$)/; +const REGEX_FIND_HIGHLIGHTED_CONT = /(?<=\s)\*[^*\s](?:[^*]*[^*\s])?\*(?=\s|$)/; + +function* format_highlights_and_links(line) { + let regex = REGEX_FIND_HIGHLIGHTED; + while (true) { + const match = line.match(regex); + regex = REGEX_FIND_HIGHLIGHTED_CONT; + const normal = line.substring(0, match?.index ?? line.length); + for (const part of format_links(normal)) { + yield part; + } + if (!match) { + return; + } + line = line.substring(match.index + match[0].length); + yield m("strong", ["*", ...format_links(match[0].slice(1, -1)), "*"]) + } +} + +function format_information(item) { + const information = item.base.information || ""; + const paragraphs = []; + let cur_lines = []; + + for (const line of information.split(/\r?\n/)) { + if (!line.trim()) { + // split paragraphs + if (cur_lines.length) { + paragraphs.push(cur_lines); + cur_lines = []; + } + continue; + } + /* find `*...*` highlighted text */ + const line_parts = []; + if (cur_lines.length) { + line_parts.push(m("br")); + } + line_parts.push(...format_highlights_and_links(line)); + cur_lines.push(m("[", line_parts)); + } + if (cur_lines.length) { + paragraphs.push(cur_lines); + } + return m("[", paragraphs.map((para) => m("p", para))); +} + +var AppointmentLocation = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + + const location_parts = []; + if (item.base.address?.meetingAt) { + location_parts.push(item.base.address.meetingAt); + } + if (item.base.address?.street) { + location_parts.push(item.base.address.street); + } + const location = location_parts.join(", "); + + if (!location) { + return m("["); + } + + return m("p", [ + fa_solid_icon(settings, "location-dot"), + " ", + location, + ]); + } +} + +var AppointmentLink = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + if (!item.base.link) { + return m("["); + } + + let url = item.base.link; + if (!url.startsWith("http")) { + url = "https://" + url; + } + + return m("p", [ + fa_solid_icon(settings, "link"), + " ", + m("a", {href: url, target: "_blank"}, item.base.link), + ]) + } +} + +function AppointmentDetails() { + let collapsed = true; + + function toggle() { + collapsed = !collapsed; + } + + return { + view: function(vnode) { + const {item, settings} = vnode.attrs; + + const show_details = item.base.information /* || item.base.address?.meetingAt || item.base.address?.street */ || item.base.link; + + if (!show_details) { + return m("["); + } + return m( + ".cte-details-collapser", + { + class: collapsed ? "collapsed" : "", + }, + [ + m(".cte-details", [ + m(".cte-information-group", [ + m("h3.cte-information-label", "Beschreibung"), + m(".cte-information", [ + format_information(item), + m(AppointmentLocation,{item, settings}), + m(AppointmentLink, {item, settings}), + ]), + ]), + ]), + m( + "button.toggle-collapse", + {onclick: toggle}, + collapsed ? "Details anzeigen" : "Details verbergen", + " ", + fa_solid_icon(settings, collapsed ? "chevron-down" : "chevron-up"), + ), + ], + ); + } + } +}; + +var Appointment = { + view: function(vnode) { + const {item, settings} = vnode.attrs; + + return m(".cte-event", [ + m(".cte-summary", [ + m(AppointmentImage, {item, settings}), + m(".cte-info", [ + m(AppointmentHeader, {item, settings}), + m(AppointmentDescription, {item, settings}), + ]), + ]), + m(AppointmentDetails, {item, settings}), + ]) + }, +}; + +async function load_events(dom_node, defaults) { + if (dom_node.dataset.cteActive) { + return; + } + dom_node.dataset.cteActive = true; + + const settings = { + instance: dom_node.dataset.cteInstance ?? defaults.instance, + days: dom_node.dataset.cteDays ?? defaults.days ?? 31, + calendar_regex: dom_node.dataset.cteCalendarRegex ?? defaults.calendar_regex, + event_regex: dom_node.dataset.cteEventRegex ?? defaults.event_regex, + limit: dom_node.dataset.cteLimit ?? defaults.limit ?? 5, + default_image: dom_node.dataset.cteDefaultImage ?? defaults.default_image, + default_image_mobile: dom_node.dataset.cteDefaultImageMobile ?? defaults.default_image_mobile, + fontawesome_svg_path: dom_node.dataset.cteFontawesomeSvgPath ?? defaults.fontawesome_svg_path, + }; + + function format_date(date) { + return date.toISOString().split('T')[0]; + } + + const req_config = { + credentials: "omit", + mode: "cors", + } + + const r_cal_regex = new RegExp(settings.calendar_regex, "i"); + const r_event_regex = new RegExp(settings.event_regex, "i"); + const all_calendars = (await (await fetch(settings.instance + "/api/calendars", req_config)).json()).data; + const calendars = all_calendars.filter((calendar) => r_cal_regex.exec(calendar.name)); + const calendar_ids = calendars.map((calendar) => calendar.id); + + const filters = calendar_ids.map((cal_id) => `calendar_ids[]=${cal_id}`); + const from = new Date(); + filters.push(`from=${format_date(from)}`); + const to = new Date(from.valueOf() + settings.days * 86400 * 1000); + filters.push(`to=${format_date(to)}`); + const query = filters.join("&"); + const all_appointments = (await (await fetch(settings.instance + `/api/calendars/appointments?${query}`, req_config)).json()).data; + const appointments = all_appointments.filter((appointment) => r_event_regex.exec(appointment.base.title)); + + if (appointments.length > settings.limit) { + appointments.length = settings.limit; + } + + const Appointments = { + view: function(vnode) { + return m("[", appointments.map((item) => m(Appointment, {item, settings}))); + } + }; + + m.mount(dom_node, Appointments); +} + +async function load_all_events(defaults) { + await Promise.all(Array.from(document.querySelectorAll(".cte-events")).map((node) => load_events(node, defaults))); +} + +export { load_all_events };