Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/config_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions src/components/CalendarDownloadButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
className="calendar-download"
disabled={items.length === 0}
onClick={() => downloadListIcs(items, calendar)}
title={title}
>
<HiCalendarDays aria-hidden="true" />
{label}
</button>
);
};

CalendarDownloadButton.propTypes = {
items: PropTypes.array.isRequired,
calendar: PropTypes.shape({
name: PropTypes.string.isRequired,
fileSuffix: PropTypes.string.isRequired,
}).isRequired,
};

export default CalendarDownloadButton;
25 changes: 14 additions & 11 deletions src/components/FilterableProgram.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -339,17 +340,19 @@ const FilterableProgram = () => {
/>
</div>
{limitDropDown()}
<div className="result-filters">
<div className="stack">
<div className="filter-total">{totalMessage}</div>
<div className="filter-expand">
<button disabled={allExpanded} onClick={expandAll}>
{configData.EXPAND?.EXPAND_ALL_LABEL ?? "Expand All"}
</button>
<button disabled={noneExpanded} onClick={collapseAll}>
{configData.EXPAND?.COLLAPSE_ALL_LABEL ?? "Collapse All"}
</button>
</div>
<div className="result-controls">
<div className="filter-total">{totalMessage}</div>
<CalendarDownloadButton
items={filtered}
calendar={{ name: configData.APP_TITLE, fileSuffix: "programme" }}
/>
<div className="filter-expand">
<button disabled={allExpanded} onClick={expandAll}>
{configData.EXPAND?.EXPAND_ALL_LABEL ?? "Expand All"}
</button>
<button disabled={noneExpanded} onClick={collapseAll}>
{configData.EXPAND?.COLLAPSE_ALL_LABEL ?? "Collapse All"}
</button>
</div>
<div className="filter-options">
<ShowPastItems />
Expand Down
27 changes: 17 additions & 10 deletions src/components/MySchedule.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -44,16 +45,22 @@ const MySchedule = () => {
<div className="introduction">
{configData.PROGRAM.MY_SCHEDULE.INTRO}
</div>
<div className="result-filters">
<div className="stack">
<div className="filter-expand">
<button disabled={allSelectedExpanded} onClick={expandSelected}>
{configData.EXPAND?.EXPAND_ALL_LABEL ?? "Expand All"}
</button>
<button disabled={noneExpanded} onClick={collapseSelected}>
{configData.EXPAND?.COLLAPSE_ALL_LABEL ?? "Collapse All"}
</button>
</div>
<div className="result-controls">
<CalendarDownloadButton
items={filtered}
calendar={{
name:
configData.APP_TITLE + " – " + configData.PROGRAM.MY_SCHEDULE.TITLE,
fileSuffix: "my-schedule",
}}
/>
<div className="filter-expand">
<button disabled={allSelectedExpanded} onClick={expandSelected}>
{configData.EXPAND?.EXPAND_ALL_LABEL ?? "Expand All"}
</button>
<button disabled={noneExpanded} onClick={collapseSelected}>
{configData.EXPAND?.COLLAPSE_ALL_LABEL ?? "Collapse All"}
</button>
</div>
<div className="filter-options">
<ShowPastItems />
Expand Down
6 changes: 3 additions & 3 deletions src/components/ProgramItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -73,14 +73,14 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => {
else locations.push(<Location key={item.loc} loc={item.loc} venue={venueForLocation(item.loc, configData)} />);

const calendarLink =
configData.CALENDARLINK && configData.CALENDARLINK.SHOW && configData.INTERACTIVE ? (
calendarLinkEnabled() ? (
<div className="item-calendarlink">
<a
href="#add-to-calendar"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
downloadIcs(item);
downloadItemIcs(item);
}}
title={configData.CALENDARLINK.TITLE}
>
Expand Down
6 changes: 4 additions & 2 deletions src/configSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion src/config_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 70 additions & 19 deletions src/utils/ICalendar.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEvent } from "ics";
import { createEvent, createEvents } from "ics";
import configData from "../config.json";

const MAX_SLUG_LENGTH = 60;
Expand All @@ -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");
}
Loading