Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions
Subscribe to a calendar published at a URL
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays";
|
||||
import { subscriptionCalendarId } from "@/store/calendar";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { formatWeekday } from "@/lib/datetime";
|
||||
@@ -65,6 +66,17 @@ export function CalendarSidebar() {
|
||||
|
||||
if (!cal.available) return null;
|
||||
const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar);
|
||||
const subscriptions = useSettings((st) => st.settings.icalSubscriptions);
|
||||
/*
|
||||
* Refreshed when the calendar is opened, and not on a timer. ihasmail has
|
||||
* nowhere to run a schedule -- no worker, no server-side state -- so the
|
||||
* honest guarantee is that a subscription is as current as the last time
|
||||
* somebody looked, which is also when it matters.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (subscriptions.length) void useCalendar.getState().refreshSubscriptions();
|
||||
}, [subscriptions]);
|
||||
|
||||
/*
|
||||
* The cards have to be loaded for there to be any birthdays to derive, and
|
||||
* the calendar is a view somebody can land on directly without ever opening
|
||||
@@ -109,6 +121,26 @@ export function CalendarSidebar() {
|
||||
<span className="cal-name">{t("Birthdays")}</span>
|
||||
</div>
|
||||
)}
|
||||
{subscriptions.map((sub) => {
|
||||
const id = subscriptionCalendarId(sub.id);
|
||||
const failed = cal.subscriptionErrors[sub.id];
|
||||
const count = cal.subscriptionEvents[sub.id]?.length ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`cal-list-item ${cal.hidden[id] ? "hidden-cal" : ""}`}
|
||||
onClick={() => cal.toggleHidden(id)}
|
||||
title={failed ? t("Could not read this calendar: {reason}", { reason: failed }) : t("Subscribed to {url}", { url: sub.url })}
|
||||
>
|
||||
<span className="cal-color" style={{ background: sub.color, borderColor: sub.color }} />
|
||||
<span className="cal-name">{sub.name}</span>
|
||||
{/* A subscription that cannot be read says so here rather than
|
||||
drawing an empty calendar, which looks like a calendar with
|
||||
nothing in it. */}
|
||||
{failed ? <AlertTriangle size={12} className="faint" aria-label={t("Could not be read")} /> : count === 0 ? null : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{calendars.map((c) => (
|
||||
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
|
||||
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
||||
|
||||
@@ -61,6 +61,49 @@ export function CalendarSettings() {
|
||||
))}
|
||||
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
|
||||
|
||||
<h2>{t("Subscribed calendars")}</h2>
|
||||
<p className="hint" style={{ marginTop: -8 }}>
|
||||
{t("A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.")}
|
||||
</p>
|
||||
{s.icalSubscriptions.map((sub) => (
|
||||
<div key={sub.id} className="card">
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: sub.color, width: 14, height: 14 }} />
|
||||
<h3>{sub.name}</h3>
|
||||
<button
|
||||
className="icon-btn sm danger"
|
||||
aria-label={t("Remove subscription")}
|
||||
onClick={() => update({ icalSubscriptions: s.icalSubscriptions.filter((x) => x.id !== sub.id) })}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="hint truncate notranslate" translate="no">{sub.url}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ColorSwatches value={sub.color} onChange={(c) => update({ icalSubscriptions: s.icalSubscriptions.map((x) => (x.id === sub.id ? { ...x, color: c } : x)) })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
const url = await promptDialog({ title: t("Subscribe to a calendar"), placeholder: "https://example.com/calendar.ics" });
|
||||
if (!url?.trim()) return;
|
||||
const name = await promptDialog({ title: t("What is it called?"), defaultValue: t("Subscribed calendar"), placeholder: t("Name") });
|
||||
if (!name?.trim()) return;
|
||||
update({
|
||||
icalSubscriptions: [
|
||||
...s.icalSubscriptions,
|
||||
// webcal: is how these are almost always published; it is an
|
||||
// https URL wearing a different word, and the server treats it so.
|
||||
{ id: `ics${Date.now()}`, url: url.trim(), name: name.trim(), color: CALENDAR_COLORS[s.icalSubscriptions.length % CALENDAR_COLORS.length]! },
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus size={16} /> {t("Subscribe to a calendar")}
|
||||
</button>
|
||||
|
||||
<h2>{t("Birthdays")}</h2>
|
||||
<Switch
|
||||
checked={s.birthdayCalendar}
|
||||
|
||||
Reference in New Issue
Block a user