From 0965fbc7c158f779f3d3b450c8c7332807d35427 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:29:14 +0100 Subject: [PATCH] feat: add iCal/webcal subscription support to calendar view - Add subscribe option to toolbar import dropdown menu - Add right-click context menu on subscribed calendars (refresh/unsubscribe) - Show refresh spinner on subscription calendars during sync - Support webcal:// protocol URLs (auto-converts to https://) - Update i18n strings with webcal:// references --- app/[locale]/calendar/page.tsx | 12 ++ .../calendar/calendar-sidebar-panel.tsx | 105 ++++++++++++++++-- components/calendar/calendar-toolbar.tsx | 52 ++++++++- .../calendar/ical-subscription-modal.tsx | 7 +- locales/en/common.json | 4 +- 5 files changed, 163 insertions(+), 17 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index f5358428..94fb0b9d 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -27,6 +27,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan import { EventModal } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; +import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; @@ -65,6 +66,7 @@ export default function CalendarPage() { const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); + const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); const [defaultModalEndDate, setDefaultModalEndDate] = useState(); @@ -706,6 +708,7 @@ export default function CalendarPage() { onViewModeChange={setViewMode} onCreateEvent={() => openCreateModal()} onImport={() => setShowImportModal(true)} + onSubscribe={() => setShowSubscriptionModal(true)} isMobile={isMobile} calendars={calendars} selectedCalendarIds={selectedCalendarIds} @@ -734,6 +737,8 @@ export default function CalendarPage() { onColorChange={client ? (calendarId, color) => { updateCalendar(client, calendarId, { color }); } : undefined} + onSubscribe={() => setShowSubscriptionModal(true)} + client={client} /> )} @@ -820,6 +825,13 @@ export default function CalendarPage() { /> )} + {showSubscriptionModal && client && ( + setShowSubscriptionModal(false)} + /> + )} + void; onColorChange?: (calendarId: string, color: string) => void; + onSubscribe?: () => void; + client?: JMAPClient | null; } export function CalendarSidebarPanel({ @@ -20,22 +24,37 @@ export function CalendarSidebarPanel({ selectedCalendarIds, onToggleVisibility, onColorChange, + onSubscribe, + client, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); + const tSub = useTranslations("calendar.subscription"); const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar); + const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions); + const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); + const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); const [colorPickerId, setColorPickerId] = useState(null); + const [contextMenuCalId, setContextMenuCalId] = useState(null); + const [refreshingSubId, setRefreshingSubId] = useState(null); const colorPickerRef = useRef(null); + const contextMenuRef = useRef(null); useEffect(() => { - if (!colorPickerId) return; + if (!colorPickerId && !contextMenuCalId) return; const handleClick = (e: MouseEvent) => { if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) { setColorPickerId(null); } + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenuCalId(null); + } }; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setColorPickerId(null); + if (e.key === 'Escape') { + setColorPickerId(null); + setContextMenuCalId(null); + } }; document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleKey); @@ -43,9 +62,38 @@ export function CalendarSidebarPanel({ document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleKey); }; - }, [colorPickerId]); + }, [colorPickerId, contextMenuCalId]); - if (calendars.length === 0) return null; + const getSubscriptionForCalendar = (calendarId: string) => { + return icalSubscriptions.find(s => s.calendarId === calendarId); + }; + + const handleRefreshSubscription = async (subId: string) => { + if (!client) return; + setRefreshingSubId(subId); + setContextMenuCalId(null); + try { + await refreshICalSubscription(client, subId); + toast.success(tSub('refresh_success')); + } catch { + toast.error(tSub('refresh_error')); + } finally { + setRefreshingSubId(null); + } + }; + + const handleUnsubscribe = async (subId: string) => { + if (!client) return; + setContextMenuCalId(null); + try { + await removeICalSubscription(client, subId); + toast.success(tSub('deleted')); + } catch { + toast.error(tSub('delete_error')); + } + }; + + if (calendars.length === 0 && !onSubscribe) return null; return (
@@ -62,9 +110,13 @@ export function CalendarSidebarPanel({ + {/* Subscription context menu on right-click */} + {contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => { + const sub = getSubscriptionForCalendar(cal.id); + if (!sub) return null; + return ( +
+ + + {sub.lastRefreshed && ( +
+ {tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })} +
+ )} +
+ ); + })()} + {/* Color picker popover on right-click */} {colorPickerId === cal.id && onColorChange && (
void; onCreateEvent: () => void; onImport?: () => void; + onSubscribe?: () => void; isMobile?: boolean; firstDayOfWeek?: number; onNavigateBack?: () => void; @@ -35,6 +36,7 @@ export function CalendarToolbar({ onViewModeChange, onCreateEvent, onImport, + onSubscribe, isMobile, firstDayOfWeek = 1, calendars, @@ -87,9 +89,22 @@ export function CalendarToolbar({ } }; + const [showImportDropdown, setShowImportDropdown] = useState(false); + const importDropdownRef = useRef(null); const [showViewDropdown, setShowViewDropdown] = useState(false); const viewDropdownRef = useRef(null); + useEffect(() => { + if (!showImportDropdown) return; + function handleClickOutside(e: MouseEvent) { + if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) { + setShowImportDropdown(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showImportDropdown]); + useEffect(() => { if (!showViewDropdown) return; function handleClickOutside(e: MouseEvent) { @@ -219,11 +234,36 @@ export function CalendarToolbar({
)} - {onImport && !isMobile && ( - + {(onImport || onSubscribe) && !isMobile && ( +
+ + {showImportDropdown && ( +
+ {onImport && ( + + )} + {onSubscribe && ( + + )} +
+ )} +
)} {!isMobile && ( diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 401ccfc9..23ef58d9 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -30,9 +30,14 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal const isValid = url.trim().length > 0 && name.trim().length > 0; const handleSubmit = useCallback(async () => { - const trimmedUrl = url.trim(); + let trimmedUrl = url.trim(); if (!trimmedUrl || !name.trim()) return; + // Convert webcal:// to https:// + if (trimmedUrl.startsWith("webcal://")) { + trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://"); + } + try { new URL(trimmedUrl); } catch { diff --git a/locales/en/common.json b/locales/en/common.json index 50852f7e..901549e0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1676,9 +1676,9 @@ "subscription": { "title": "iCal Subscription", "section_title": "iCal Subscriptions", - "description": "Subscribe to an external iCalendar feed. Events will be synced automatically into their own calendar.", + "description": "Subscribe to an external iCalendar feed. Events will be synced automatically into their own calendar. Supports https:// and webcal:// URLs.", "url_label": "Calendar URL", - "url_placeholder": "https://example.com/calendar.ics", + "url_placeholder": "https://example.com/calendar.ics or webcal://...", "name_label": "Calendar name", "name_placeholder": "e.g. Public Holidays", "color_label": "Color",