feat: implement calendar management features including create, update, and delete functionalities
This commit is contained in:
@@ -51,7 +51,8 @@ export default function CalendarPage() {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||
const { identities } = useIdentityStore();
|
||||
@@ -97,6 +98,16 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
|
||||
// Auto-refresh iCal subscriptions
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
// Refresh on mount (respects per-subscription interval)
|
||||
refreshAllSubscriptions(client);
|
||||
// Check again every 5 minutes
|
||||
const interval = setInterval(() => refreshAllSubscriptions(client), 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (viewMode) {
|
||||
@@ -712,6 +723,9 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
onColorChange={client ? (calendarId, color) => {
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
@@ -84,7 +85,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'security' && <AccountSecuritySettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <CalendarSettings />}
|
||||
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
function isValidExternalUrl(urlString: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('169.254.') ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block URLs with credentials
|
||||
if (url.username || url.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { url } = body;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidExternalUrl(url)) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_RESPONSE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar',
|
||||
'Content-Length': buffer.byteLength.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe } 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";
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarPanel({
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPickerId) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
||||
setColorPickerId(null);
|
||||
}
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setColorPickerId(null);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [colorPickerId]);
|
||||
|
||||
if (calendars.length === 0) return null;
|
||||
|
||||
@@ -30,25 +58,53 @@ export function CalendarSidebarPanel({
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cal.id}
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
if (onColorChange) {
|
||||
e.preventDefault();
|
||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"hover:bg-muted"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
</button>
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
{isSubscriptionCalendar(cal.id) && (
|
||||
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Color picker popover on right-click */}
|
||||
{colorPickerId === cal.id && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => {
|
||||
onColorChange(cal.id, c);
|
||||
setColorPickerId(null);
|
||||
}}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,8 @@ function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||
}
|
||||
|
||||
function parseDuration(duration: string): number {
|
||||
function parseDuration(duration: string | undefined): number {
|
||||
if (!duration) return 0;
|
||||
let totalMinutes = 0;
|
||||
const weekMatch = duration.match(/(\d+)W/);
|
||||
const hourMatch = duration.match(/(\d+)H/);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Upload, Check, Loader2, RefreshCw } from "lucide-react";
|
||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
@@ -20,6 +20,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
@@ -38,6 +39,9 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("file");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -103,6 +107,57 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
if (file) handleFile(file);
|
||||
}, [handleFile]);
|
||||
|
||||
const handleUrlFetch = useCallback(async () => {
|
||||
const trimmed = urlInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
setError(t("invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsFetchingUrl(true);
|
||||
setIsParsing(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/fetch-ical", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: trimmed }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || t("url_fetch_failed"));
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "calendar.ics", { type: "text/calendar" });
|
||||
const uploaded = await client.uploadBlob(file);
|
||||
const accountId = client.getCalendarsAccountId();
|
||||
const events = await client.parseCalendarEvents(accountId, uploaded.blobId);
|
||||
|
||||
if (events.length === 0) {
|
||||
setError(t("no_events"));
|
||||
setIsFetchingUrl(false);
|
||||
setIsParsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedEvents(events);
|
||||
setSelectedIndices(new Set(events.map((_, i) => i)));
|
||||
setStep("preview");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("url_fetch_failed"));
|
||||
} finally {
|
||||
setIsFetchingUrl(false);
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [urlInput, client, t]);
|
||||
|
||||
const toggleEvent = useCallback((index: number) => {
|
||||
setSelectedIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -202,29 +257,85 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{step === "select" && !isParsing && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">{t("select_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics,.ical"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex border-b border-border mb-4">
|
||||
<button
|
||||
onClick={() => { setImportMode("file"); setError(null); }}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
importMode === "file"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("tab_file")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setImportMode("url"); setError(null); }}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
importMode === "url"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t("tab_url")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{importMode === "file" && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">{t("select_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics,.ical"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importMode === "url" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder={t("url_placeholder")}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlFetch}
|
||||
disabled={!urlInput.trim() || isFetchingUrl}
|
||||
>
|
||||
{isFetchingUrl ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
t("fetch")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("url_hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isParsing && (
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Globe } from "lucide-react";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalSubscriptionModalProps {
|
||||
client: JMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(60);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isValid = url.trim().length > 0 && name.trim().length > 0;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl || !name.trim()) return;
|
||||
|
||||
try {
|
||||
new URL(trimmedUrl);
|
||||
} catch {
|
||||
setError(t("invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const subscription = await addICalSubscription(client, trimmedUrl, name.trim(), color, refreshInterval);
|
||||
if (subscription) {
|
||||
toast.success(t("success", { name: name.trim() }));
|
||||
onClose();
|
||||
} else {
|
||||
setError(t("error"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("error"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstEl = focusableEls[0];
|
||||
const lastEl = focusableEls[focusableEls.length - 1];
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
if (e.shiftKey && document.activeElement === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl?.focus();
|
||||
}
|
||||
};
|
||||
modal.addEventListener("keydown", handler);
|
||||
firstEl?.focus();
|
||||
return () => modal.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("url_label")}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={t("url_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name_label")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("name_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("color_label")}
|
||||
</label>
|
||||
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("refresh_interval")}
|
||||
</label>
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value={15}>{t("interval_15")}</option>
|
||||
<option value={30}>{t("interval_30")}</option>
|
||||
<option value={60}>{t("interval_60")}</option>
|
||||
<option value={360}>{t("interval_360")}</option>
|
||||
<option value={1440}>{t("interval_1440")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{t("subscribing")}
|
||||
</>
|
||||
) : (
|
||||
t("subscribe")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Check, X, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
|
||||
const CALENDAR_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#ef4444", // red
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#14b8a6", // teal
|
||||
"#f97316", // orange
|
||||
"#06b6d4", // cyan
|
||||
"#84cc16", // lime
|
||||
"#6366f1", // indigo
|
||||
"#a855f7", // purple
|
||||
"#e11d48", // rose
|
||||
"#0ea5e9", // sky
|
||||
"#10b981", // emerald
|
||||
"#d946ef", // fuchsia
|
||||
];
|
||||
|
||||
function CalendarColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
allowCustom,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
allowCustom?: boolean;
|
||||
}) {
|
||||
const selectedIsPreset = CALENDAR_COLORS.includes(value);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{CALENDAR_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
value === color && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={color}
|
||||
/>
|
||||
))}
|
||||
{allowCustom && (
|
||||
<label
|
||||
className={cn(
|
||||
"relative w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 overflow-hidden border-2 border-dashed border-muted-foreground/40",
|
||||
!selectedIsPreset && value && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
style={!selectedIsPreset && value ? { backgroundColor: value } : undefined}
|
||||
title="Custom color"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={value || "#3b82f6"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
/>
|
||||
{(selectedIsPreset || !value) && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-muted-foreground text-xs font-bold">+</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEditForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}: {
|
||||
initial?: { name: string; color: string };
|
||||
onSave: (data: { name: string; color: string }) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const t = useTranslations('calendar.management');
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
const [color, setColor] = useState(initial?.color || '#3b82f6');
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t('name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isValid) onSave({ name: name.trim(), color });
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
placeholder={t('name_placeholder')}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t('color')}
|
||||
</label>
|
||||
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => isValid && onSave({ name: name.trim(), color })}
|
||||
disabled={isLoading || !isValid}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{initial ? t('save') : t('create')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [deletingSubId, setDeletingSubId] = useState<string | null>(null);
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const tImport = useTranslations('calendar.import');
|
||||
const tSub = useTranslations('calendar.subscription');
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load calendars if not yet loaded
|
||||
useEffect(() => {
|
||||
if (client && calendars.length === 0) {
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, calendars.length, fetchCalendars]);
|
||||
|
||||
const handleRefreshSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setRefreshingSubId(subId);
|
||||
try {
|
||||
await refreshICalSubscription(client, subId);
|
||||
toast.success(tSub('refresh_success'));
|
||||
} catch {
|
||||
toast.error(tSub('refresh_error'));
|
||||
} finally {
|
||||
setRefreshingSubId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await removeICalSubscription(client, subId);
|
||||
setDeletingSubId(null);
|
||||
toast.success(tSub('deleted'));
|
||||
} catch {
|
||||
toast.error(tSub('delete_error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close color picker on click outside
|
||||
useEffect(() => {
|
||||
if (!colorPickerId) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
||||
setColorPickerId(null);
|
||||
}
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setColorPickerId(null);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [colorPickerId]);
|
||||
|
||||
const handleCreate = async (data: { name: string; color: string }) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createCalendar(client, {
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
isVisible: true,
|
||||
isSubscribed: true,
|
||||
});
|
||||
setIsCreating(false);
|
||||
toast.success(t('calendar_created'));
|
||||
} catch {
|
||||
toast.error(t('error_create'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (calendarId: string, data: { name: string; color: string }) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateCalendar(client, calendarId, { name: data.name, color: data.color });
|
||||
setEditingId(null);
|
||||
toast.success(t('calendar_updated'));
|
||||
} catch {
|
||||
toast.error(t('error_update'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = async (calendarId: string, color: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await updateCalendar(client, calendarId, { color });
|
||||
toast.success(t('color_updated'));
|
||||
} catch {
|
||||
toast.error(t('error_update'));
|
||||
}
|
||||
setColorPickerId(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (calendarId: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await removeCalendar(client, calendarId);
|
||||
setDeletingId(null);
|
||||
toast.success(t('calendar_deleted'));
|
||||
} catch {
|
||||
toast.error(t('error_delete'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async (calendarId: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const count = await clearCalendarEvents(client, calendarId);
|
||||
setClearingId(null);
|
||||
toast.success(t('events_cleared', { count }));
|
||||
} catch {
|
||||
toast.error(t('error_clear'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildCalDavUrl = (calendarId: string) => {
|
||||
if (!serverUrl || !username) return null;
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||
};
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success(t('url_copied'));
|
||||
} catch {
|
||||
// Fallback for non-HTTPS contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
toast.success(t('url_copied'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="space-y-2">
|
||||
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
|
||||
const color = cal.color || '#3b82f6';
|
||||
|
||||
if (editingId === cal.id) {
|
||||
return (
|
||||
<CalendarEditForm
|
||||
key={cal.id}
|
||||
initial={{ name: cal.name, color }}
|
||||
onSave={(data) => handleUpdate(cal.id, data)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deletingId === cal.id) {
|
||||
return (
|
||||
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
|
||||
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
<p className="text-sm text-foreground flex-1">
|
||||
{t('confirm_delete', { name: cal.name })}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleDelete(cal.id)}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clearingId === cal.id) {
|
||||
return (
|
||||
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-amber-500/5 rounded-md border border-amber-500/20">
|
||||
<Eraser className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<p className="text-sm text-foreground flex-1">
|
||||
{t('confirm_clear', { name: cal.name })}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleClear(cal.id)}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{t('clear_events')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClearingId(null)}
|
||||
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cal.id}
|
||||
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
>
|
||||
{/* Color swatch - clickable to change color */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColorPickerId(colorPickerId === cal.id ? null : cal.id)}
|
||||
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: color }}
|
||||
title={t('change_color')}
|
||||
/>
|
||||
|
||||
{/* Inline color picker popover */}
|
||||
{colorPickerId === cal.id && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-0 top-full mt-2 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => handleColorChange(cal.id, c)}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{cal.name}</span>
|
||||
{(() => {
|
||||
const caldavUrl = buildCalDavUrl(cal.id);
|
||||
if (!caldavUrl) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs text-muted-foreground truncate" title={caldavUrl}>
|
||||
{caldavUrl}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyUrl(caldavUrl);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
title={t('copy_url')}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{cal.isDefault && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{t('default')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('edit')}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
title={t('clear_events')}
|
||||
>
|
||||
<Eraser className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{!cal.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={t('delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isCreating ? (
|
||||
<CalendarEditForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex items-center gap-2 flex-1 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('add_calendar')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{tImport('title')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubscriptionModal(true)}
|
||||
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{tSub('title')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* iCal Subscriptions */}
|
||||
{icalSubscriptions.length > 0 && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
{tSub('section_title')}
|
||||
</h4>
|
||||
{icalSubscriptions.map((sub) => {
|
||||
if (deletingSubId === sub.id) {
|
||||
return (
|
||||
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
|
||||
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
<p className="text-sm text-foreground flex-1">
|
||||
{tSub('confirm_delete', { name: sub.name })}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleDeleteSubscription(sub.id)}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingSubId(null)}
|
||||
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: sub.color }}
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{sub.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
|
||||
{sub.url}
|
||||
</span>
|
||||
{sub.lastRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tSub('last_refreshed', { time: new Date(sub.lastRefreshed).toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
disabled={refreshingSubId === sub.id}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
title={tSub('refresh')}
|
||||
>
|
||||
<RefreshCw className={cn("w-3.5 h-3.5", refreshingSubId === sub.id && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingSubId(sub.id)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={tSub('unsubscribe')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImportModal && client && (
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
+46
-24
@@ -2052,7 +2052,8 @@ export class JMAPClient {
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
destroy: [calendarId]
|
||||
destroy: [calendarId],
|
||||
onDestroyRemoveEvents: true
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
@@ -2070,30 +2071,31 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
queryArgs.filter = { inCalendars: calendarIds };
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendar events:', error);
|
||||
return [];
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
queryArgs.filter = { inCalendars: calendarIds };
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
// Check for JMAP method-level errors
|
||||
if (response.methodResponses?.[0]?.[0] === "error") {
|
||||
const error = response.methodResponses[0][1];
|
||||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||||
}
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async queryCalendarEvents(
|
||||
@@ -2107,7 +2109,7 @@ export class JMAPClient {
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter,
|
||||
limit: limit || 100,
|
||||
limit: limit || 1000,
|
||||
};
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
@@ -2279,6 +2281,26 @@ export class JMAPClient {
|
||||
throw new Error("Failed to delete calendar event");
|
||||
}
|
||||
|
||||
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||||
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: string[] = [];
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.destroyed) destroyed.push(...result.destroyed);
|
||||
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
||||
}
|
||||
|
||||
return { destroyed, notDestroyed };
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await this.authenticatedFetch(url, {});
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Weiter",
|
||||
"import": {
|
||||
"title": "Kalender importieren",
|
||||
"tab_file": "Datei",
|
||||
"tab_url": "URL",
|
||||
"select_file": ".ics-Datei auswählen",
|
||||
"drop_file": "oder Datei hier ablegen",
|
||||
"supported_formats": "iCalendar (.ics) Dateien werden unterstützt",
|
||||
"url_description": "Geben Sie die URL eines externen iCalendar (.ics) Feeds ein, um Termine zu importieren.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Unterstützt CalDAV und iCalendar (.ics) URLs",
|
||||
"fetch": "Abrufen",
|
||||
"invalid_url": "Bitte geben Sie eine gültige URL ein",
|
||||
"url_fetch_failed": "Kalender konnte nicht von der URL abgerufen werden",
|
||||
"parsing": "Kalenderdatei wird analysiert...",
|
||||
"parsed_events": "{count} Termine gefunden",
|
||||
"no_events": "Keine Termine in der Datei gefunden",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Kalender konnte nicht importiert werden",
|
||||
"file_too_large": "Datei überschreitet das 5-MB-Limit",
|
||||
"invalid_format": "Ungültiges Kalenderdateiformat"
|
||||
},
|
||||
"management": {
|
||||
"title": "Kalenderverwaltung",
|
||||
"description": "Erstellen, umbenennen und anpassen Ihrer Kalender. Rechtsklick auf einen Kalender in der Seitenleiste, um die Farbe schnell zu ändern.",
|
||||
"name": "Name",
|
||||
"name_placeholder": "Kalendername",
|
||||
"color": "Farbe",
|
||||
"change_color": "Farbe ändern",
|
||||
"add_calendar": "Kalender hinzufügen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"create": "Erstellen",
|
||||
"cancel": "Abbrechen",
|
||||
"default": "Standard",
|
||||
"confirm_delete": "\"{name}\" löschen? Alle Termine in diesem Kalender werden entfernt.",
|
||||
"calendar_created": "Kalender erstellt",
|
||||
"calendar_updated": "Kalender aktualisiert",
|
||||
"calendar_deleted": "Kalender gelöscht",
|
||||
"color_updated": "Kalenderfarbe aktualisiert",
|
||||
"error_create": "Kalender konnte nicht erstellt werden",
|
||||
"error_update": "Kalender konnte nicht aktualisiert werden",
|
||||
"error_delete": "Kalender konnte nicht gelöscht werden",
|
||||
"caldav_url": "CalDAV-URL",
|
||||
"copy_url": "CalDAV-URL kopieren",
|
||||
"url_copied": "CalDAV-URL in die Zwischenablage kopiert"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Next",
|
||||
"import": {
|
||||
"title": "Import Calendar",
|
||||
"tab_file": "File",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Select .ics file",
|
||||
"drop_file": "or drop file here",
|
||||
"supported_formats": "Supports iCalendar (.ics) files",
|
||||
"url_description": "Enter the URL of an external iCalendar (.ics) feed to import events.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Supports CalDAV and iCalendar (.ics) URLs",
|
||||
"fetch": "Fetch",
|
||||
"invalid_url": "Please enter a valid URL",
|
||||
"url_fetch_failed": "Failed to fetch calendar from URL",
|
||||
"parsing": "Parsing calendar file...",
|
||||
"parsed_events": "{count} events found",
|
||||
"no_events": "No events found in file",
|
||||
@@ -1613,6 +1621,65 @@
|
||||
"error": "Failed to import calendar",
|
||||
"file_too_large": "File exceeds 5MB limit",
|
||||
"invalid_format": "Invalid calendar file format"
|
||||
},
|
||||
"management": {
|
||||
"title": "Calendar Management",
|
||||
"description": "Create, rename, and customize your calendars. Right-click a calendar in the sidebar to quickly change its color.",
|
||||
"name": "Name",
|
||||
"name_placeholder": "Calendar name",
|
||||
"color": "Color",
|
||||
"change_color": "Change color",
|
||||
"add_calendar": "Add calendar",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"default": "Default",
|
||||
"confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.",
|
||||
"confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.",
|
||||
"clear_events": "Clear events",
|
||||
"events_cleared": "{count} events cleared",
|
||||
"error_clear": "Failed to clear calendar events",
|
||||
"calendar_created": "Calendar created",
|
||||
"calendar_updated": "Calendar updated",
|
||||
"calendar_deleted": "Calendar deleted",
|
||||
"color_updated": "Calendar color updated",
|
||||
"error_create": "Failed to create calendar",
|
||||
"error_update": "Failed to update calendar",
|
||||
"error_delete": "Failed to delete calendar",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "Copy CalDAV URL",
|
||||
"url_copied": "CalDAV URL copied to clipboard"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal Subscription",
|
||||
"section_title": "iCal Subscriptions",
|
||||
"description": "Subscribe to an external iCalendar feed. Events will be synced automatically into their own calendar.",
|
||||
"url_label": "Calendar URL",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"name_label": "Calendar name",
|
||||
"name_placeholder": "e.g. Public Holidays",
|
||||
"color_label": "Color",
|
||||
"refresh_interval": "Refresh interval",
|
||||
"interval_15": "Every 15 minutes",
|
||||
"interval_30": "Every 30 minutes",
|
||||
"interval_60": "Every hour",
|
||||
"interval_360": "Every 6 hours",
|
||||
"interval_1440": "Every day",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribing": "Subscribing...",
|
||||
"invalid_url": "Please enter a valid URL",
|
||||
"success": "Subscribed to \"{name}\"",
|
||||
"error": "Failed to add subscription",
|
||||
"refresh": "Refresh now",
|
||||
"refresh_success": "Subscription refreshed",
|
||||
"refresh_error": "Failed to refresh subscription",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"confirm_delete": "Unsubscribe from \"{name}\"? The calendar and all its events will be removed.",
|
||||
"deleted": "Subscription removed",
|
||||
"delete_error": "Failed to remove subscription",
|
||||
"last_refreshed": "Last updated: {time}"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Siguiente",
|
||||
"import": {
|
||||
"title": "Importar calendario",
|
||||
"tab_file": "Archivo",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Seleccionar archivo .ics",
|
||||
"drop_file": "o arrastra el archivo aquí",
|
||||
"supported_formats": "Archivos iCalendar (.ics) compatibles",
|
||||
"url_description": "Introduce la URL de un feed iCalendar (.ics) externo para importar eventos.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Compatible con URLs CalDAV e iCalendar (.ics)",
|
||||
"fetch": "Obtener",
|
||||
"invalid_url": "Introduce una URL válida",
|
||||
"url_fetch_failed": "No se pudo obtener el calendario desde la URL",
|
||||
"parsing": "Analizando archivo de calendario...",
|
||||
"parsed_events": "{count} eventos encontrados",
|
||||
"no_events": "No se encontraron eventos en el archivo",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Error al importar el calendario",
|
||||
"file_too_large": "El archivo supera el límite de 5 MB",
|
||||
"invalid_format": "Formato de archivo de calendario no válido"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestión de calendarios",
|
||||
"description": "Crea, renombra y personaliza tus calendarios. Haz clic derecho en un calendario en la barra lateral para cambiar su color rápidamente.",
|
||||
"name": "Nombre",
|
||||
"name_placeholder": "Nombre del calendario",
|
||||
"color": "Color",
|
||||
"change_color": "Cambiar color",
|
||||
"add_calendar": "Añadir calendario",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"save": "Guardar",
|
||||
"create": "Crear",
|
||||
"cancel": "Cancelar",
|
||||
"default": "Predeterminado",
|
||||
"confirm_delete": "¿Eliminar \"{name}\"? Se eliminarán todos los eventos de este calendario.",
|
||||
"calendar_created": "Calendario creado",
|
||||
"calendar_updated": "Calendario actualizado",
|
||||
"calendar_deleted": "Calendario eliminado",
|
||||
"color_updated": "Color del calendario actualizado",
|
||||
"error_create": "Error al crear el calendario",
|
||||
"error_update": "Error al actualizar el calendario",
|
||||
"error_delete": "Error al eliminar el calendario",
|
||||
"caldav_url": "URL de CalDAV",
|
||||
"copy_url": "Copiar URL de CalDAV",
|
||||
"url_copied": "URL de CalDAV copiada al portapapeles"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Suivant",
|
||||
"import": {
|
||||
"title": "Importer un calendrier",
|
||||
"tab_file": "Fichier",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Sélectionner un fichier .ics",
|
||||
"drop_file": "ou déposez le fichier ici",
|
||||
"supported_formats": "Fichiers iCalendar (.ics) supportés",
|
||||
"url_description": "Entrez l'URL d'un flux iCalendar (.ics) externe pour importer des événements.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Prend en charge les URLs CalDAV et iCalendar (.ics)",
|
||||
"fetch": "Récupérer",
|
||||
"invalid_url": "Veuillez entrer une URL valide",
|
||||
"url_fetch_failed": "Impossible de récupérer le calendrier depuis l'URL",
|
||||
"parsing": "Analyse du fichier en cours...",
|
||||
"parsed_events": "{count} événements trouvés",
|
||||
"no_events": "Aucun événement trouvé dans le fichier",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Échec de l'importation du calendrier",
|
||||
"file_too_large": "Le fichier dépasse la limite de 5 Mo",
|
||||
"invalid_format": "Format de fichier calendrier invalide"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestion des calendriers",
|
||||
"description": "Créez, renommez et personnalisez vos calendriers. Clic droit sur un calendrier dans la barre latérale pour changer rapidement sa couleur.",
|
||||
"name": "Nom",
|
||||
"name_placeholder": "Nom du calendrier",
|
||||
"color": "Couleur",
|
||||
"change_color": "Changer la couleur",
|
||||
"add_calendar": "Ajouter un calendrier",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"save": "Enregistrer",
|
||||
"create": "Créer",
|
||||
"cancel": "Annuler",
|
||||
"default": "Par défaut",
|
||||
"confirm_delete": "Supprimer \"{name}\" ? Tous les événements de ce calendrier seront supprimés.",
|
||||
"calendar_created": "Calendrier créé",
|
||||
"calendar_updated": "Calendrier mis à jour",
|
||||
"calendar_deleted": "Calendrier supprimé",
|
||||
"color_updated": "Couleur du calendrier mise à jour",
|
||||
"error_create": "Échec de la création du calendrier",
|
||||
"error_update": "Échec de la mise à jour du calendrier",
|
||||
"error_delete": "Échec de la suppression du calendrier",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Copier l'URL CalDAV",
|
||||
"url_copied": "URL CalDAV copiée dans le presse-papiers"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Successivo",
|
||||
"import": {
|
||||
"title": "Importa calendario",
|
||||
"tab_file": "File",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Seleziona file .ics",
|
||||
"drop_file": "o trascina il file qui",
|
||||
"supported_formats": "File iCalendar (.ics) supportati",
|
||||
"url_description": "Inserisci l'URL di un feed iCalendar (.ics) esterno per importare eventi.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Supporta URL CalDAV e iCalendar (.ics)",
|
||||
"fetch": "Recupera",
|
||||
"invalid_url": "Inserisci un URL valido",
|
||||
"url_fetch_failed": "Impossibile recuperare il calendario dall'URL",
|
||||
"parsing": "Analisi del file in corso...",
|
||||
"parsed_events": "{count} eventi trovati",
|
||||
"no_events": "Nessun evento trovato nel file",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Importazione del calendario fallita",
|
||||
"file_too_large": "Il file supera il limite di 5 MB",
|
||||
"invalid_format": "Formato del file calendario non valido"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gestione calendari",
|
||||
"description": "Crea, rinomina e personalizza i tuoi calendari. Fai clic destro su un calendario nella barra laterale per cambiarne rapidamente il colore.",
|
||||
"name": "Nome",
|
||||
"name_placeholder": "Nome del calendario",
|
||||
"color": "Colore",
|
||||
"change_color": "Cambia colore",
|
||||
"add_calendar": "Aggiungi calendario",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"save": "Salva",
|
||||
"create": "Crea",
|
||||
"cancel": "Annulla",
|
||||
"default": "Predefinito",
|
||||
"confirm_delete": "Eliminare \"{name}\"? Tutti gli eventi in questo calendario verranno rimossi.",
|
||||
"calendar_created": "Calendario creato",
|
||||
"calendar_updated": "Calendario aggiornato",
|
||||
"calendar_deleted": "Calendario eliminato",
|
||||
"color_updated": "Colore del calendario aggiornato",
|
||||
"error_create": "Impossibile creare il calendario",
|
||||
"error_update": "Impossibile aggiornare il calendario",
|
||||
"error_delete": "Impossibile eliminare il calendario",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Copia URL CalDAV",
|
||||
"url_copied": "URL CalDAV copiato negli appunti"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "次へ",
|
||||
"import": {
|
||||
"title": "カレンダーをインポート",
|
||||
"tab_file": "ファイル",
|
||||
"tab_url": "URL",
|
||||
"select_file": ".icsファイルを選択",
|
||||
"drop_file": "またはファイルをここにドロップ",
|
||||
"supported_formats": "iCalendar (.ics) ファイルに対応",
|
||||
"url_description": "外部のiCalendar (.ics) フィードのURLを入力してイベントをインポートします。",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "CalDAVおよびiCalendar (.ics) URLに対応",
|
||||
"fetch": "取得",
|
||||
"invalid_url": "有効なURLを入力してください",
|
||||
"url_fetch_failed": "URLからカレンダーを取得できませんでした",
|
||||
"parsing": "カレンダーファイルを解析中...",
|
||||
"parsed_events": "{count}件のイベントが見つかりました",
|
||||
"no_events": "ファイルにイベントが見つかりません",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "カレンダーのインポートに失敗しました",
|
||||
"file_too_large": "ファイルサイズが5MBを超えています",
|
||||
"invalid_format": "無効なカレンダーファイル形式"
|
||||
},
|
||||
"management": {
|
||||
"title": "カレンダー管理",
|
||||
"description": "カレンダーの作成、名前変更、カスタマイズができます。サイドバーのカレンダーを右クリックして色を素早く変更できます。",
|
||||
"name": "名前",
|
||||
"name_placeholder": "カレンダー名",
|
||||
"color": "色",
|
||||
"change_color": "色を変更",
|
||||
"add_calendar": "カレンダーを追加",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"save": "保存",
|
||||
"create": "作成",
|
||||
"cancel": "キャンセル",
|
||||
"default": "デフォルト",
|
||||
"confirm_delete": "\"{name}\"を削除しますか?このカレンダーのすべてのイベントが削除されます。",
|
||||
"calendar_created": "カレンダーを作成しました",
|
||||
"calendar_updated": "カレンダーを更新しました",
|
||||
"calendar_deleted": "カレンダーを削除しました",
|
||||
"color_updated": "カレンダーの色を更新しました",
|
||||
"error_create": "カレンダーの作成に失敗しました",
|
||||
"error_update": "カレンダーの更新に失敗しました",
|
||||
"error_delete": "カレンダーの削除に失敗しました",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "CalDAV URLをコピー",
|
||||
"url_copied": "CalDAV URLをクリップボードにコピーしました"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Volgende",
|
||||
"import": {
|
||||
"title": "Agenda importeren",
|
||||
"tab_file": "Bestand",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Selecteer .ics-bestand",
|
||||
"drop_file": "of sleep het bestand hierheen",
|
||||
"supported_formats": "iCalendar (.ics) bestanden worden ondersteund",
|
||||
"url_description": "Voer de URL in van een externe iCalendar (.ics) feed om evenementen te importeren.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Ondersteunt CalDAV en iCalendar (.ics) URLs",
|
||||
"fetch": "Ophalen",
|
||||
"invalid_url": "Voer een geldige URL in",
|
||||
"url_fetch_failed": "Kan agenda niet ophalen van URL",
|
||||
"parsing": "Agendabestand wordt verwerkt...",
|
||||
"parsed_events": "{count} evenementen gevonden",
|
||||
"no_events": "Geen evenementen gevonden in bestand",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Agenda importeren mislukt",
|
||||
"file_too_large": "Bestand overschrijdt de limiet van 5 MB",
|
||||
"invalid_format": "Ongeldig agendabestandsformaat"
|
||||
},
|
||||
"management": {
|
||||
"title": "Agendabeheer",
|
||||
"description": "Maak, hernoem en pas uw agenda's aan. Klik met de rechtermuisknop op een agenda in de zijbalk om snel de kleur te wijzigen.",
|
||||
"name": "Naam",
|
||||
"name_placeholder": "Agendanaam",
|
||||
"color": "Kleur",
|
||||
"change_color": "Kleur wijzigen",
|
||||
"add_calendar": "Agenda toevoegen",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"save": "Opslaan",
|
||||
"create": "Aanmaken",
|
||||
"cancel": "Annuleren",
|
||||
"default": "Standaard",
|
||||
"confirm_delete": "\"{name}\" verwijderen? Alle afspraken in deze agenda worden verwijderd.",
|
||||
"calendar_created": "Agenda aangemaakt",
|
||||
"calendar_updated": "Agenda bijgewerkt",
|
||||
"calendar_deleted": "Agenda verwijderd",
|
||||
"color_updated": "Agendakleur bijgewerkt",
|
||||
"error_create": "Agenda aanmaken mislukt",
|
||||
"error_update": "Agenda bijwerken mislukt",
|
||||
"error_delete": "Agenda verwijderen mislukt",
|
||||
"caldav_url": "CalDAV-URL",
|
||||
"copy_url": "CalDAV-URL kopiëren",
|
||||
"url_copied": "CalDAV-URL gekopieerd naar klembord"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -1598,9 +1598,17 @@
|
||||
"nav_next": "Próximo",
|
||||
"import": {
|
||||
"title": "Importar calendário",
|
||||
"tab_file": "Arquivo",
|
||||
"tab_url": "URL",
|
||||
"select_file": "Selecionar arquivo .ics",
|
||||
"drop_file": "ou arraste o arquivo aqui",
|
||||
"supported_formats": "Arquivos iCalendar (.ics) suportados",
|
||||
"url_description": "Insira a URL de um feed iCalendar (.ics) externo para importar eventos.",
|
||||
"url_placeholder": "https://example.com/calendar.ics",
|
||||
"url_hint": "Suporta URLs CalDAV e iCalendar (.ics)",
|
||||
"fetch": "Buscar",
|
||||
"invalid_url": "Insira uma URL válida",
|
||||
"url_fetch_failed": "Não foi possível buscar o calendário da URL",
|
||||
"parsing": "Analisando arquivo de calendário...",
|
||||
"parsed_events": "{count} eventos encontrados",
|
||||
"no_events": "Nenhum evento encontrado no arquivo",
|
||||
@@ -1613,6 +1621,32 @@
|
||||
"error": "Falha ao importar calendário",
|
||||
"file_too_large": "Arquivo excede o limite de 5 MB",
|
||||
"invalid_format": "Formato de arquivo de calendário inválido"
|
||||
},
|
||||
"management": {
|
||||
"title": "Gerenciamento de calendários",
|
||||
"description": "Crie, renomeie e personalize seus calendários. Clique com o botão direito em um calendário na barra lateral para alterar rapidamente sua cor.",
|
||||
"name": "Nome",
|
||||
"name_placeholder": "Nome do calendário",
|
||||
"color": "Cor",
|
||||
"change_color": "Alterar cor",
|
||||
"add_calendar": "Adicionar calendário",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir",
|
||||
"save": "Salvar",
|
||||
"create": "Criar",
|
||||
"cancel": "Cancelar",
|
||||
"default": "Padrão",
|
||||
"confirm_delete": "Excluir \"{name}\"? Todos os eventos neste calendário serão removidos.",
|
||||
"calendar_created": "Calendário criado",
|
||||
"calendar_updated": "Calendário atualizado",
|
||||
"calendar_deleted": "Calendário excluído",
|
||||
"color_updated": "Cor do calendário atualizada",
|
||||
"error_create": "Falha ao criar calendário",
|
||||
"error_update": "Falha ao atualizar calendário",
|
||||
"error_delete": "Falha ao excluir calendário",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Copiar URL CalDAV",
|
||||
"url_copied": "URL CalDAV copiada para a área de transferência"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
|
||||
@@ -6,6 +6,16 @@ import { debug } from '@/lib/debug';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
|
||||
export interface ICalSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
calendarId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
refreshInterval: number; // minutes
|
||||
lastRefreshed: string | null;
|
||||
}
|
||||
|
||||
interface CalendarStore {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
@@ -27,11 +37,23 @@ interface CalendarStore {
|
||||
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string) => Promise<void>;
|
||||
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
||||
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>;
|
||||
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>;
|
||||
setSelectedDate: (date: Date) => void;
|
||||
setViewMode: (mode: CalendarViewMode) => void;
|
||||
toggleCalendarVisibility: (calendarId: string) => void;
|
||||
setSelectedEventId: (id: string | null) => void;
|
||||
clearState: () => void;
|
||||
|
||||
// iCal subscriptions
|
||||
icalSubscriptions: ICalSubscription[];
|
||||
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
||||
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
|
||||
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
|
||||
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>;
|
||||
isSubscriptionCalendar: (calendarId: string) => boolean;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
@@ -45,6 +67,7 @@ const initialState = {
|
||||
supportsCalendar: false,
|
||||
error: null as string | null,
|
||||
dateRange: null as { start: string; end: string } | null,
|
||||
icalSubscriptions: [] as ICalSubscription[],
|
||||
};
|
||||
|
||||
export const useCalendarStore = create<CalendarStore>()(
|
||||
@@ -265,6 +288,92 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
setSelectedDate: (date) => set({ selectedDate: date }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
|
||||
updateCalendar: async (client, calendarId, updates) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.updateCalendar(calendarId, updates);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.map(c =>
|
||||
c.id === calendarId ? { ...c, ...updates } : c
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to update calendar:', error);
|
||||
set({ error: 'Failed to update calendar' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createCalendar: async (client, calendar) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const created = await client.createCalendar(calendar);
|
||||
set((state) => ({
|
||||
calendars: [...state.calendars, created],
|
||||
selectedCalendarIds: [...state.selectedCalendarIds, created.id],
|
||||
}));
|
||||
return created;
|
||||
} catch (error) {
|
||||
debug.error('Failed to create calendar:', error);
|
||||
set({ error: 'Failed to create calendar' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
removeCalendar: async (client, calendarId) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.deleteCalendar(calendarId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
|
||||
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to delete calendar:', error);
|
||||
set({ error: 'Failed to delete calendar' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearCalendarEvents: async (client, calendarId) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
let totalDeleted = 0;
|
||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
// Query all events and filter client-side by calendarId
|
||||
// to avoid relying on server-side inCalendars filter support
|
||||
const allEvents = await client.getCalendarEvents();
|
||||
const calendarEvents = allEvents.filter(e => e.calendarIds?.[calendarId]);
|
||||
if (calendarEvents.length === 0) break;
|
||||
|
||||
const ids = calendarEvents.map(e => e.id);
|
||||
const { destroyed } = await client.batchDeleteCalendarEvents(ids);
|
||||
totalDeleted += destroyed.length;
|
||||
|
||||
// If we couldn't destroy any events, stop to avoid infinite loop
|
||||
if (destroyed.length === 0) {
|
||||
debug.warn('Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we got fewer than the limit, we've fetched everything
|
||||
if (allEvents.length < 1000) hasMore = false;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||
}));
|
||||
return totalDeleted;
|
||||
} catch (error) {
|
||||
debug.error('Failed to clear calendar events:', error);
|
||||
set({ error: 'Failed to clear calendar events' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
toggleCalendarVisibility: (calendarId) => set((state) => {
|
||||
const ids = state.selectedCalendarIds;
|
||||
return {
|
||||
@@ -276,6 +385,171 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
setSelectedEventId: (id) => set({ selectedEventId: id }),
|
||||
|
||||
// iCal subscriptions
|
||||
isSubscriptionCalendar: (calendarId) => {
|
||||
return get().icalSubscriptions.some(s => s.calendarId === calendarId);
|
||||
},
|
||||
|
||||
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
|
||||
try {
|
||||
// Create a new calendar for this subscription
|
||||
const calendar = await client.createCalendar({
|
||||
name,
|
||||
color,
|
||||
isVisible: true,
|
||||
isSubscribed: true,
|
||||
});
|
||||
if (!calendar) throw new Error('Failed to create calendar');
|
||||
|
||||
const subscription: ICalSubscription = {
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
calendarId: calendar.id,
|
||||
name,
|
||||
color,
|
||||
refreshInterval,
|
||||
lastRefreshed: null,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
calendars: [...state.calendars, calendar],
|
||||
selectedCalendarIds: [...state.selectedCalendarIds, calendar.id],
|
||||
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
||||
}));
|
||||
|
||||
// Do initial fetch
|
||||
try {
|
||||
await get().refreshICalSubscription(client, subscription.id);
|
||||
} catch {
|
||||
// Subscription created, initial fetch failed - user can retry
|
||||
debug.warn('Initial subscription fetch failed for:', name);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
debug.error('Failed to add iCal subscription:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
removeICalSubscription: async (client, subscriptionId) => {
|
||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
try {
|
||||
await client.deleteCalendar(sub.calendarId);
|
||||
} catch (error) {
|
||||
debug.error('Failed to delete subscription calendar:', error);
|
||||
// Continue removing subscription record even if calendar delete fails
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
icalSubscriptions: state.icalSubscriptions.filter(s => s.id !== subscriptionId),
|
||||
calendars: state.calendars.filter(c => c.id !== sub.calendarId),
|
||||
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId),
|
||||
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
|
||||
}));
|
||||
},
|
||||
|
||||
refreshICalSubscription: async (client, subscriptionId) => {
|
||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/fetch-ical', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: sub.url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to fetch calendar');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'subscription.ics', { type: 'text/calendar' });
|
||||
const uploaded = await client.uploadBlob(file);
|
||||
const accountId = client.getCalendarsAccountId();
|
||||
const parsedEvents = await client.parseCalendarEvents(accountId, uploaded.blobId);
|
||||
|
||||
// Fetch ALL server-side events and filter client-side for this calendar
|
||||
// (avoids relying on server-side inCalendars filter support)
|
||||
const allServerEvents = await client.getCalendarEvents();
|
||||
const serverEvents = allServerEvents.filter(e => e.calendarIds?.[sub.calendarId]);
|
||||
|
||||
// Build a map of incoming UIDs for diffing
|
||||
const incomingUids = new Set(parsedEvents.map(e => e.uid).filter(Boolean));
|
||||
|
||||
// Build a map of existing UIDs on server
|
||||
const existingByUid = new Map<string, CalendarEvent[]>();
|
||||
for (const e of serverEvents) {
|
||||
if (e.uid) {
|
||||
const list = existingByUid.get(e.uid) || [];
|
||||
list.push(e);
|
||||
existingByUid.set(e.uid, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete events that are no longer in the feed
|
||||
const idsToDelete = serverEvents
|
||||
.filter(e => !e.uid || !incomingUids.has(e.uid))
|
||||
.map(e => e.id);
|
||||
if (idsToDelete.length > 0) {
|
||||
await client.batchDeleteCalendarEvents(idsToDelete);
|
||||
}
|
||||
|
||||
// Import only events that don't already exist on server
|
||||
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
||||
|
||||
// Remove stale local events for this calendar
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]),
|
||||
}));
|
||||
|
||||
// Import new events
|
||||
if (eventsToImport.length > 0) {
|
||||
await get().importEvents(client, eventsToImport, sub.calendarId);
|
||||
}
|
||||
|
||||
// Re-fetch ALL events from server and filter for this calendar
|
||||
const allUpdatedEvents = await client.getCalendarEvents();
|
||||
const updatedEvents = allUpdatedEvents.filter(e => e.calendarIds?.[sub.calendarId]);
|
||||
set((state) => {
|
||||
const otherEvents = state.events.filter(e => !e.calendarIds?.[sub.calendarId]);
|
||||
return { events: [...otherEvents, ...updatedEvents] };
|
||||
});
|
||||
|
||||
// Update last refreshed timestamp
|
||||
set((state) => ({
|
||||
icalSubscriptions: state.icalSubscriptions.map(s =>
|
||||
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to refresh iCal subscription:', sub.name, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
refreshAllSubscriptions: async (client) => {
|
||||
const { icalSubscriptions } = get();
|
||||
const now = Date.now();
|
||||
|
||||
for (const sub of icalSubscriptions) {
|
||||
const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
|
||||
const intervalMs = sub.refreshInterval * 60 * 1000;
|
||||
|
||||
if (now - lastRefreshed >= intervalMs) {
|
||||
try {
|
||||
await get().refreshICalSubscription(client, sub.id);
|
||||
} catch {
|
||||
debug.warn('Failed to refresh subscription:', sub.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearState: () => {
|
||||
set({
|
||||
...initialState,
|
||||
@@ -291,6 +565,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
partialize: (state) => ({
|
||||
selectedCalendarIds: state.selectedCalendarIds,
|
||||
viewMode: state.viewMode,
|
||||
icalSubscriptions: state.icalSubscriptions,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user