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
This commit is contained in:
Linus Rath
2026-03-15 16:29:14 +01:00
parent d6cf195bfc
commit 0965fbc7c1
5 changed files with 163 additions and 17 deletions
+12
View File
@@ -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<CalendarEvent | null>(null);
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
@@ -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}
/>
</div>
)}
@@ -820,6 +825,13 @@ export default function CalendarPage() {
/>
)}
{showSubscriptionModal && client && (
<ICalSubscriptionModal
client={client}
onClose={() => setShowSubscriptionModal(false)}
/>
)}
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
+97 -8
View File
@@ -2,17 +2,21 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Globe } from "lucide-react";
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
onToggleVisibility: (id: string) => 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<string | null>(null);
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(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 (
<div className="mt-4">
@@ -62,9 +110,13 @@ export function CalendarSidebarPanel({
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={(e) => {
if (onColorChange) {
e.preventDefault();
e.preventDefault();
if (isSubscriptionCalendar(cal.id) && client) {
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
setColorPickerId(null);
} else if (onColorChange) {
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
setContextMenuCalId(null);
}
}}
className={cn(
@@ -83,10 +135,47 @@ export function CalendarSidebarPanel({
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<>
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
)}
</>
)}
</button>
{/* Subscription context menu on right-click */}
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
const sub = getSubscriptionForCalendar(cal.id);
if (!sub) return null;
return (
<div
ref={contextMenuRef}
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
>
<button
onClick={() => handleRefreshSubscription(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
{tSub('refresh')}
</button>
<button
onClick={() => handleUnsubscribe(sub.id)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
{tSub('unsubscribe')}
</button>
{sub.lastRefreshed && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
</div>
)}
</div>
);
})()}
{/* Color picker popover on right-click */}
{colorPickerId === cal.id && onColorChange && (
<div
+46 -6
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -18,6 +18,7 @@ interface CalendarToolbarProps {
onViewModeChange: (mode: CalendarViewMode) => 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<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(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({
</div>
)}
{onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
</Button>
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
{onImport && (
<button
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
{onSubscribe && (
<button
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Globe className="w-4 h-4" />
{t("subscription.title")}
</button>
)}
</div>
)}
</div>
)}
{!isMobile && (
@@ -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 {
+2 -2
View File
@@ -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",