"use client"; import { useState, useCallback, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; 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 { IJMAPClient } from '@/lib/jmap/client-interface'; import { getEventStartDate } from "@/lib/calendar-utils"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import { apiFetch } from "@/lib/browser-navigation"; interface ICalImportModalProps { calendars: Calendar[]; client: IJMAPClient; onClose: () => void; initialUrl?: string; } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { const t = useTranslations("calendar.import"); const tCal = useTranslations("calendar"); const tCommon = useTranslations("common"); const tForm = useTranslations("calendar.form"); const importEvents = useCalendarStore((s) => s.importEvents); const timeFormat = useSettingsStore((s) => s.timeFormat); const [step, setStep] = useState("select"); const [parsedEvents, setParsedEvents] = useState[]>([]); const [selectedIndices, setSelectedIndices] = useState>(new Set()); const [calendarId, setCalendarId] = useState(() => { const defaultCal = calendars.find((c) => c.isDefault); return defaultCal?.id || calendars[0]?.id || ""; }); const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); const [importMode, setImportMode] = useState(initialUrl ? "url" : "file"); const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); const modalRef = useRef(null); const validateFile = useCallback((file: File): string | null => { if (file.size > MAX_FILE_SIZE) return t("file_too_large"); const ext = file.name.toLowerCase().slice(file.name.lastIndexOf(".")); if (!ACCEPTED_EXTENSIONS.includes(ext)) return t("invalid_format"); return null; }, [t]); const handleFile = useCallback(async (file: File) => { const validationError = validateFile(file); if (validationError) { setError(validationError); return; } setError(null); setIsParsing(true); setStep("select"); try { const blob = new File([file], file.name, { type: "text/calendar" }); const uploaded = await client.uploadBlob(blob); const accountId = client.getCalendarsAccountId(); const events = await client.parseCalendarEvents(accountId, uploaded.blobId); if (events.length === 0) { setError(t("no_events")); setIsParsing(false); return; } setParsedEvents(events); setSelectedIndices(new Set(events.map((_, i) => i))); setStep("preview"); } catch { setError(t("invalid_format")); } finally { setIsParsing(false); } }, [client, validateFile, t]); const handleFileChange = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) handleFile(file); }, [handleFile]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files[0]; 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 apiFetch("/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); if (next.has(index)) next.delete(index); else next.add(index); return next; }); }, []); const toggleAll = useCallback(() => { if (selectedIndices.size === parsedEvents.length) { setSelectedIndices(new Set()); } else { setSelectedIndices(new Set(parsedEvents.map((_, i) => i))); } }, [selectedIndices.size, parsedEvents]); const handleImport = useCallback(async () => { const eventsToImport = parsedEvents.filter((_, i) => selectedIndices.has(i)); if (eventsToImport.length === 0) return; setStep("importing"); try { const count = await importEvents(client, eventsToImport, calendarId); toast.success(t("success", { count })); onClose(); } catch { toast.error(t("error")); setStep("preview"); } }, [parsedEvents, selectedIndices, importEvents, client, calendarId, t, onClose]); const formatEventDate = (event: Partial): string => { if (!event.start) return ""; try { const date = getEventStartDate(event as CalendarEvent); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; return event.showWithoutTime ? format(date, "MMM d, yyyy") : format(date, `MMM d, yyyy ${timeFmt}`); } catch { return event.start; } }; 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( '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); }, [step]); return (
); }