diff --git a/docs/config_reference.md b/docs/config_reference.md index 750a142..7899278 100644 --- a/docs/config_reference.md +++ b/docs/config_reference.md @@ -120,8 +120,10 @@ Copy `src/config_example.json` to `src/config.json` and customise. See the [Gett - `PERMALINK.SHOW_PERMALINK` (boolean) — If true, display a "permalink" icon when each program item is expanded. - `PERMALINK.PERMALINK_TITLE` (string) — "Title" text displayed when the mouse is hovered over the permalink icon. - `CALENDARLINK` (object) -- `CALENDARLINK.SHOW` (boolean) — If true, display a calendar download icon when each program item is expanded. +- `CALENDARLINK.SHOW` (boolean) — If true, display a calendar download icon when each program item is expanded, and a button on the programme and My Schedule pages to download all listed items as a single calendar. - `CALENDARLINK.TITLE` (string) — "Title" text displayed when the mouse is hovered over the calendar download icon. +- `CALENDARLINK.DOWNLOAD_ALL_LABEL` (string) — Label for the button that downloads all listed items as a calendar. "@count" is replaced by the number of items. Defaults to "Download calendar (@count items)". +- `CALENDARLINK.DOWNLOAD_ALL_TITLE` (string) — "Title" text displayed when the mouse is hovered over the download all button. Defaults to "Download an .ics file to import into your calendar app". - `EXPAND` (object) - `EXPAND.EXPAND_ALL_LABEL` (string) — Label text for the Expand All button. - `EXPAND.COLLAPSE_ALL_LABEL` (string) — Label for the Collapse All button. diff --git a/src/App.css b/src/App.css index ee43411..2e4c2e6 100644 --- a/src/App.css +++ b/src/App.css @@ -992,6 +992,83 @@ div.switch-wrapper { pointer-events: none; } +.result-controls { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "total download" + "expand options"; + align-items: center; + column-gap: 1rem; + margin: 0.5em; + margin-bottom: 1rem; +} + +.result-controls .filter-total { + grid-area: total; +} + +.result-controls .calendar-download { + grid-area: download; + justify-self: end; +} + +.result-controls .filter-expand { + grid-area: expand; +} + +.result-controls .filter-options { + grid-area: options; + justify-self: end; + margin: 0.5em 0 0; +} + +.result-controls .switch-wrapper { + margin: 0; +} + +@media (max-width: 32rem) { + .result-controls { + grid-template-columns: 1fr; + grid-template-areas: + "total" + "download" + "expand" + "options"; + } + + .result-controls .calendar-download, + .result-controls .filter-options { + justify-self: start; + } +} + +.calendar-download { + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.25rem 0; + border: none; + background: none; + color: var(--link-fg); + font: inherit; + font-size: smaller; + text-decoration: none; + cursor: pointer; +} + +.calendar-download:hover, +.calendar-download:focus { + color: var(--link-hover-fg); + text-decoration: underline; +} + +.calendar-download:disabled { + color: var(--link-disabled); + text-decoration: none; + cursor: default; +} + .reset-filters { display: flex; flex-direction: row; diff --git a/src/components/CalendarDownloadButton.jsx b/src/components/CalendarDownloadButton.jsx new file mode 100644 index 0000000..6a03554 --- /dev/null +++ b/src/components/CalendarDownloadButton.jsx @@ -0,0 +1,38 @@ +import PropTypes from "prop-types"; +import { HiCalendarDays } from "react-icons/hi2"; +import configData from "../config.json"; +import { calendarLinkEnabled, downloadListIcs } from "../utils/ICalendar"; + +const CalendarDownloadButton = ({ items, calendar }) => { + if (!calendarLinkEnabled()) { + return null; + } + const label = ( + configData.CALENDARLINK.DOWNLOAD_ALL_LABEL ?? + "Download calendar (@count items)" + ).replace("@count", items.length); + const title = + configData.CALENDARLINK.DOWNLOAD_ALL_TITLE ?? + "Download an .ics file to import into your calendar app"; + return ( + + ); +}; + +CalendarDownloadButton.propTypes = { + items: PropTypes.array.isRequired, + calendar: PropTypes.shape({ + name: PropTypes.string.isRequired, + fileSuffix: PropTypes.string.isRequired, + }).isRequired, +}; + +export default CalendarDownloadButton; diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index 11d62ea..5170409 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -8,6 +8,7 @@ import TagSelectors from "./TagSelectors"; import ResetButton from "./ResetButton"; import ProgramList from "./ProgramList"; import ShowPastItems from "./ShowPastItems"; +import CalendarDownloadButton from "./CalendarDownloadButton"; import { LocalTime } from "../utils/LocalTime"; import { buildLocationOptions, locationMatchesSelection } from "../utils/Venues"; @@ -339,17 +340,19 @@ const FilterableProgram = () => { /> {limitDropDown()} -
-
-
{totalMessage}
-
- - -
+
+
{totalMessage}
+ +
+ +
diff --git a/src/components/MySchedule.jsx b/src/components/MySchedule.jsx index 316f4f0..5c19e0b 100644 --- a/src/components/MySchedule.jsx +++ b/src/components/MySchedule.jsx @@ -3,6 +3,7 @@ import configData from "../config.json"; import ProgramList from "./ProgramList"; import ShowPastItems from "./ShowPastItems"; import ShareLink from "./ShareLink"; +import CalendarDownloadButton from "./CalendarDownloadButton"; import { LocalTime } from "../utils/LocalTime"; const MySchedule = () => { @@ -44,16 +45,22 @@ const MySchedule = () => {
{configData.PROGRAM.MY_SCHEDULE.INTRO}
-
-
-
- - -
+
+ +
+ +
diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 239955d..e31dd66 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -16,7 +16,7 @@ import PropTypes from "prop-types"; import { Temporal } from "@js-temporal/polyfill"; import { useState, useEffect } from "react"; import { LocalTime } from "../utils/LocalTime"; -import { downloadIcs } from "../utils/ICalendar"; +import { calendarLinkEnabled, downloadItemIcs } from "../utils/ICalendar"; import { venueForLocation } from "../utils/Venues"; const ProgramItem = ({ item, forceExpanded = false, now }) => { @@ -73,14 +73,14 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { else locations.push(); const calendarLink = - configData.CALENDARLINK && configData.CALENDARLINK.SHOW && configData.INTERACTIVE ? ( + calendarLinkEnabled() ? (
{ event.preventDefault(); event.stopPropagation(); - downloadIcs(item); + downloadItemIcs(item); }} title={configData.CALENDARLINK.TITLE} > diff --git a/src/configSchema.json b/src/configSchema.json index e6fc075..e53f29d 100644 --- a/src/configSchema.json +++ b/src/configSchema.json @@ -442,8 +442,10 @@ "type": "object", "additionalProperties": false, "properties": { - "SHOW": { "type": "boolean", "description": "If true, display a calendar download icon when each program item is expanded." }, - "TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the calendar download icon." } + "SHOW": { "type": "boolean", "description": "If true, display a calendar download icon when each program item is expanded, and a button on the programme and My Schedule pages to download all listed items as a single calendar." }, + "TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the calendar download icon." }, + "DOWNLOAD_ALL_LABEL": { "type": "string", "description": "Label for the button that downloads all listed items as a calendar. \"@count\" is replaced by the number of items. Defaults to \"Download calendar (@count items)\"." }, + "DOWNLOAD_ALL_TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the download all button. Defaults to \"Download an .ics file to import into your calendar app\"." } } }, "EXPAND": { diff --git a/src/config_example.json b/src/config_example.json index 5a35627..58d8a18 100644 --- a/src/config_example.json +++ b/src/config_example.json @@ -147,7 +147,9 @@ }, "CALENDARLINK": { "SHOW": true, - "TITLE": "Add To Calendar" + "TITLE": "Add To Calendar", + "DOWNLOAD_ALL_LABEL": "Download calendar (@count items)", + "DOWNLOAD_ALL_TITLE": "Download an .ics file to import into your calendar app" }, "EXPAND": { "EXPAND_ALL_LABEL": "Expand All", diff --git a/src/utils/ICalendar.js b/src/utils/ICalendar.js index 6b765c0..41f1f2a 100644 --- a/src/utils/ICalendar.js +++ b/src/utils/ICalendar.js @@ -1,4 +1,4 @@ -import { createEvent } from "ics"; +import { createEvent, createEvents } from "ics"; import configData from "../config.json"; const MAX_SLUG_LENGTH = 60; @@ -18,55 +18,106 @@ function slugify(text) { : slug.slice(0, MAX_SLUG_LENGTH).replace(/-[^-]*$/, ""); } +function appPrefix() { + return slugify(configData.APP_ID) || "conclar"; +} + function filenameFor(item) { - const prefix = slugify(configData.APP_ID) || "conclar"; const title = slugify(item.title) || item.id; - return prefix + "-" + title + ".ics"; + return appPrefix() + "-" + title + ".ics"; } function describeItem(item) { - let desc = item.desc; + const parts = []; + if (item.desc) { + parts.push(item.desc); + } if (item.people && item.people.length > 0) { - const names = item.people.map((person) => person.name.toString()); - desc = "\n\n" + names.join(", "); + parts.push(item.people.map((person) => person.name.toString()).join(", ")); + } + if (parts.length === 0) { + return undefined; } - return new DOMParser().parseFromString(desc, "text/html").body.textContent; + return new DOMParser().parseFromString(parts.join("\n\n"), "text/html").body + .textContent; } -function buildIcsContent(item) { - const { error, value } = createEvent({ +/** + * Whether items offer a download to add them to a calendar app. + * + * @returns {boolean} + */ +export function calendarLinkEnabled() { + return !!(configData.CALENDARLINK?.SHOW && configData.INTERACTIVE); +} + +const PRODUCT_ID = "-//ConClar//ConClar//EN"; + +function itemUrl(item) { + if (!configData.PERMALINK?.SHOW_PERMALINK || !configData.INTERACTIVE) { + return undefined; + } + return window.location.origin + window.publicUrl + "id/" + item.id; +} + +function eventFor(item) { + return { uid: configData.APP_ID + "-" + item.id + "@" + window.location.hostname, - productId: "-//ConClar//ConClar//EN", start: toDateArray(item.startDateAndTime), startInputType: "utc", end: toDateArray(item.endDateAndTime), endInputType: "utc", title: item.title, - description: item.desc ? describeItem(item) : undefined, + description: describeItem(item), location: item.loc ? String(item.loc) : undefined, - }); - - if (error) throw error; - return value; + url: itemUrl(item), + }; } /** - * Trigger a browser download of an ICS event as a file, via a Blob and a + * Trigger a browser download of ICS content as a file, via a Blob and a * temporary object URL. More reliable across browsers (notably Safari) * than a `data:` URL with a `download` attribute. */ -export function downloadIcs(item) { - const blob = new Blob([buildIcsContent(item)], { +function downloadFile(content, filename) { + const blob = new Blob([content], { type: "text/calendar;charset=utf-8", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = filenameFor(item); + link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } + +export function downloadItemIcs(item) { + const { error, value } = createEvent({ + ...eventFor(item), + productId: PRODUCT_ID, + }); + if (error) { + throw error; + } + downloadFile(value, filenameFor(item)); +} + +/** + * Download several items as a single calendar. `calendar.name` is used by + * calendar apps that offer to import into a new calendar; `calendar.fileSuffix` + * goes into the filename after the app prefix, so must already be a slug. + */ +export function downloadListIcs(items, calendar) { + const { error, value } = createEvents(items.map(eventFor), { + productId: PRODUCT_ID, + calName: calendar.name, + }); + if (error) { + throw error; + } + downloadFile(value, appPrefix() + "-" + calendar.fileSuffix + ".ics"); +}