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:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user