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 { EventModal } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
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 { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
@@ -65,6 +66,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
@@ -706,6 +708,7 @@ export default function CalendarPage() {
|
|||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
onCreateEvent={() => openCreateModal()}
|
onCreateEvent={() => openCreateModal()}
|
||||||
onImport={() => setShowImportModal(true)}
|
onImport={() => setShowImportModal(true)}
|
||||||
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
@@ -734,6 +737,8 @@ export default function CalendarPage() {
|
|||||||
onColorChange={client ? (calendarId, color) => {
|
onColorChange={client ? (calendarId, color) => {
|
||||||
updateCalendar(client, calendarId, { color });
|
updateCalendar(client, calendarId, { color });
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
|
client={client}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -820,6 +825,13 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSubscriptionModal && client && (
|
||||||
|
<ICalSubscriptionModal
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowSubscriptionModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<RecurrenceScopeDialog
|
<RecurrenceScopeDialog
|
||||||
isOpen={!!pendingScopeAction}
|
isOpen={!!pendingScopeAction}
|
||||||
actionType={pendingScopeAction?.type || "edit"}
|
actionType={pendingScopeAction?.type || "edit"}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import type { JMAPClient } from "@/lib/jmap/client";
|
||||||
|
|
||||||
interface CalendarSidebarPanelProps {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onColorChange?: (calendarId: string, color: string) => void;
|
onColorChange?: (calendarId: string, color: string) => void;
|
||||||
|
onSubscribe?: () => void;
|
||||||
|
client?: JMAPClient | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
@@ -20,22 +24,37 @@ export function CalendarSidebarPanel({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
|
onSubscribe,
|
||||||
|
client,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
|
const tSub = useTranslations("calendar.subscription");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
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 [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 colorPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colorPickerId) return;
|
if (!colorPickerId && !contextMenuCalId) return;
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
||||||
setColorPickerId(null);
|
setColorPickerId(null);
|
||||||
}
|
}
|
||||||
|
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setColorPickerId(null);
|
if (e.key === 'Escape') {
|
||||||
|
setColorPickerId(null);
|
||||||
|
setContextMenuCalId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener('mousedown', handleClick);
|
||||||
document.addEventListener('keydown', handleKey);
|
document.addEventListener('keydown', handleKey);
|
||||||
@@ -43,9 +62,38 @@ export function CalendarSidebarPanel({
|
|||||||
document.removeEventListener('mousedown', handleClick);
|
document.removeEventListener('mousedown', handleClick);
|
||||||
document.removeEventListener('keydown', handleKey);
|
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 (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -62,9 +110,13 @@ export function CalendarSidebarPanel({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
onContextMenu={(e) => {
|
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);
|
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||||
|
setContextMenuCalId(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -83,10 +135,47 @@ export function CalendarSidebarPanel({
|
|||||||
{cal.name}
|
{cal.name}
|
||||||
</span>
|
</span>
|
||||||
{isSubscriptionCalendar(cal.id) && (
|
{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>
|
</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 */}
|
{/* Color picker popover on right-click */}
|
||||||
{colorPickerId === cal.id && onColorChange && (
|
{colorPickerId === cal.id && onColorChange && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -18,6 +18,7 @@ interface CalendarToolbarProps {
|
|||||||
onViewModeChange: (mode: CalendarViewMode) => void;
|
onViewModeChange: (mode: CalendarViewMode) => void;
|
||||||
onCreateEvent: () => void;
|
onCreateEvent: () => void;
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
|
onSubscribe?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
onNavigateBack?: () => void;
|
onNavigateBack?: () => void;
|
||||||
@@ -35,6 +36,7 @@ export function CalendarToolbar({
|
|||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onImport,
|
onImport,
|
||||||
|
onSubscribe,
|
||||||
isMobile,
|
isMobile,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -87,9 +89,22 @@ export function CalendarToolbar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
||||||
|
const importDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [showViewDropdown, setShowViewDropdown] = useState(false);
|
const [showViewDropdown, setShowViewDropdown] = useState(false);
|
||||||
const viewDropdownRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!showViewDropdown) return;
|
if (!showViewDropdown) return;
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
@@ -219,11 +234,36 @@ export function CalendarToolbar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onImport && !isMobile && (
|
{(onImport || onSubscribe) && !isMobile && (
|
||||||
<Button variant="outline" size="sm" onClick={onImport}>
|
<div className="relative" ref={importDropdownRef}>
|
||||||
<Upload className="w-4 h-4 mr-1" />
|
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
||||||
{t("import.title")}
|
<Upload className="w-4 h-4 mr-1" />
|
||||||
</Button>
|
{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 && (
|
{!isMobile && (
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModal
|
|||||||
const isValid = url.trim().length > 0 && name.trim().length > 0;
|
const isValid = url.trim().length > 0 && name.trim().length > 0;
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const trimmedUrl = url.trim();
|
let trimmedUrl = url.trim();
|
||||||
if (!trimmedUrl || !name.trim()) return;
|
if (!trimmedUrl || !name.trim()) return;
|
||||||
|
|
||||||
|
// Convert webcal:// to https://
|
||||||
|
if (trimmedUrl.startsWith("webcal://")) {
|
||||||
|
trimmedUrl = trimmedUrl.replace(/^webcal:\/\//, "https://");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(trimmedUrl);
|
new URL(trimmedUrl);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1676,9 +1676,9 @@
|
|||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "iCal Subscription",
|
"title": "iCal Subscription",
|
||||||
"section_title": "iCal Subscriptions",
|
"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_label": "Calendar URL",
|
||||||
"url_placeholder": "https://example.com/calendar.ics",
|
"url_placeholder": "https://example.com/calendar.ics or webcal://...",
|
||||||
"name_label": "Calendar name",
|
"name_label": "Calendar name",
|
||||||
"name_placeholder": "e.g. Public Holidays",
|
"name_placeholder": "e.g. Public Holidays",
|
||||||
"color_label": "Color",
|
"color_label": "Color",
|
||||||
|
|||||||
Reference in New Issue
Block a user