feat: implement calendar management features including create, update, and delete functionalities

This commit is contained in:
Linus Rath
2026-03-15 00:14:58 +01:00
parent f149501dff
commit caed067cda
18 changed files with 1772 additions and 68 deletions
+73 -17
View File
@@ -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>
+2 -1
View File
@@ -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/);
+135 -24
View File
@@ -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>
);
}