feat: add calendar drag-and-drop rescheduling and iCalendar import

- Drag events in week/day views to reschedule (15-min snap intervals)
- Drag events in month view to change date (preserves time)
- Visual snap indicators with time labels during drag
- iCalendar (.ics) file import via JMAP CalendarEvent/parse
- Import modal with file upload, event preview, calendar selector
- File validation (5MB max), bulk import with progress tracking
This commit is contained in:
Matthieu MALVACHE
2026-02-16 21:09:13 +01:00
committed by Matthieu MALVACHE
parent e6f67c24cb
commit ff65693e26
19 changed files with 842 additions and 29 deletions
+79 -2
View File
@@ -1,11 +1,14 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import { useMemo, useEffect, useRef, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
interface CalendarDayViewProps {
selectedDate: Date;
@@ -132,6 +135,57 @@ export function CalendarDayView({
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
const [dropMinutes, setDropMinutes] = useState<number | null>(null);
const snapMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const raw = (y / HOUR_HEIGHT) * 60;
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
}, []);
const formatSnapTime = useCallback((minutes: number): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (timeFormat === "12h") {
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
}
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}, [timeFormat]);
const handleDayDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const minutes = snapMinutes(e);
setDropMinutes((prev) => prev === minutes ? prev : minutes);
}, [snapMinutes]);
const handleDayDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(related)) setDropMinutes(null);
}, []);
const handleDayDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDropMinutes(null);
const json = e.dataTransfer.getData("application/x-calendar-event");
if (!json) return;
try {
const data = JSON.parse(json);
const minutes = snapMinutes(e);
const newStart = new Date(selectedDate);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
} catch {
toast.error(t("notifications.event_move_error"));
}
}, [snapMinutes, selectedDate, t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="px-4 py-3 border-b border-border">
@@ -174,7 +228,14 @@ export function CalendarDayView({
))}
</div>
<div className="flex-1 relative border-l border-border" role="row">
<div
className="flex-1 relative border-l border-border"
role="row"
aria-label={t("views.day")}
onDragOver={handleDayDragOver}
onDragLeave={handleDayDragLeave}
onDrop={handleDayDrop}
>
{HOURS.map((h) => (
<div
key={h}
@@ -211,6 +272,7 @@ export function CalendarDayView({
calendar={calendarMap.get(calId)}
variant="block"
onClick={() => onSelectEvent(ev)}
draggable
/>
</div>
);
@@ -227,6 +289,21 @@ export function CalendarDayView({
</div>
</div>
)}
{dropMinutes !== null && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
{formatSnapTime(dropMinutes)}
</div>
</div>
)}
</div>
</div>
</div>
+45 -3
View File
@@ -1,14 +1,17 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
} from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -92,6 +95,40 @@ export function CalendarMonthView({
return result;
}, [days]);
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropDayKey((prev) => prev === dayKey ? prev : dayKey);
}, []);
const handleCellDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(related)) setDropDayKey(null);
}, []);
const handleCellDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
e.preventDefault();
setDropDayKey(null);
const json = e.dataTransfer.getData("application/x-calendar-event");
if (!json) return;
try {
const data = JSON.parse(json);
const originalStart = parseISO(data.originalStart);
const newStart = new Date(day);
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
} catch {
toast.error(t("notifications.event_move_error"));
}
}, [t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="grid grid-cols-7 border-b border-border" role="row">
@@ -121,10 +158,14 @@ export function CalendarMonthView({
aria-selected={selected}
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
!inMonth && "bg-muted/30",
"hover:bg-muted/50"
"hover:bg-muted/50",
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
)}
>
<div className="flex items-center justify-center mb-0.5">
@@ -150,6 +191,7 @@ export function CalendarMonthView({
calendar={calendarMap.get(calId)}
variant="chip"
onClick={() => onSelectEvent(ev)}
draggable
/>
);
})}
+10 -1
View File
@@ -2,7 +2,7 @@
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -16,6 +16,7 @@ interface CalendarToolbarProps {
onToday: () => void;
onViewModeChange: (mode: CalendarViewMode) => void;
onCreateEvent: () => void;
onImport?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
}
@@ -29,6 +30,7 @@ export function CalendarToolbar({
onToday,
onViewModeChange,
onCreateEvent,
onImport,
isMobile,
firstDayOfWeek = 1,
}: CalendarToolbarProps) {
@@ -100,6 +102,13 @@ export function CalendarToolbar({
</div>
)}
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{!isMobile && t("import.title")}
</Button>
)}
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{!isMobile && t("events.create")}
+82 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import { useMemo, useEffect, useRef, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
@@ -8,6 +8,9 @@ import {
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
interface CalendarWeekViewProps {
selectedDate: Date;
@@ -164,6 +167,59 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const [dropTarget, setDropTarget] = useState<{ dayKey: string; minutes: number } | null>(null);
const snapMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
const raw = (y / HOUR_HEIGHT) * 60;
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
}, []);
const formatSnapTime = useCallback((minutes: number): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (timeFormat === "12h") {
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
}
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}, [timeFormat]);
const handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const minutes = snapMinutes(e);
setDropTarget((prev) =>
prev?.dayKey === dayKey && prev?.minutes === minutes ? prev : { dayKey, minutes }
);
}, [snapMinutes]);
const handleColumnDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(related)) setDropTarget(null);
}, []);
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
e.preventDefault();
setDropTarget(null);
const json = e.dataTransfer.getData("application/x-calendar-event");
if (!json) return;
try {
const data = JSON.parse(json);
const minutes = snapMinutes(e);
const newStart = new Date(day);
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO });
} catch {
toast.error(t("notifications.event_move_error"));
}
}, [snapMinutes, t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
@@ -253,7 +309,15 @@ export function CalendarWeekView({
const layouted = layoutOverlappingEvents(dayEvents);
return (
<div key={key} className="relative border-r border-border last:border-r-0" role="row">
<div
key={key}
className="relative border-r border-border last:border-r-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onDragOver={(e) => handleColumnDragOver(e, key)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
<div
key={h}
@@ -286,6 +350,7 @@ export function CalendarWeekView({
calendar={calendarMap.get(calId)}
variant="block"
onClick={() => onSelectEvent(ev)}
draggable
/>
</div>
);
@@ -302,6 +367,21 @@ export function CalendarWeekView({
</div>
</div>
)}
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
{formatSnapTime(dropTarget.minutes)}
</div>
</div>
)}
</div>
);
})}
+54 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -11,6 +12,7 @@ interface EventCardProps {
variant: "chip" | "block";
onClick?: () => void;
isSelected?: boolean;
draggable?: boolean;
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -37,8 +39,24 @@ function parseDuration(duration: string): number {
return totalMinutes;
}
export function EventCard({ event, calendar, variant, onClick, isSelected }: EventCardProps) {
function createEventDragPreview(title: string, color: string): HTMLElement {
const el = document.createElement("div");
el.style.cssText = `
position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500;
max-width: 200px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`;
el.textContent = title;
document.body.appendChild(el);
return el;
}
export function EventCard({ event, calendar, variant, onClick, isSelected, draggable: isDraggable }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
@@ -48,16 +66,47 @@ export function EventCard({ event, calendar, variant, onClick, isSelected }: Eve
const timeString = `${format(startDate, "HH:mm")} ${format(endTime, "HH:mm")}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-calendar-event", JSON.stringify({
type: "calendar-event",
eventId: event.id,
originalStart: event.start,
duration: event.duration,
durationMinutes,
}));
const displayTitle = event.title || t("events.no_title");
e.dataTransfer.setData("text/plain", displayTitle);
const preview = createEventDragPreview(displayTitle, color);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
setIsBeingDragged(true);
}, [event, color, t, durationMinutes]);
const handleDragEnd = useCallback(() => {
setIsBeingDragged(false);
}, []);
const dragProps = isDraggable ? {
draggable: true as const,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
"aria-roledescription": "draggable event",
} : {};
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary"
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
)}
style={{ backgroundColor: `${color}20`, color }}
>
@@ -74,10 +123,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected }: Eve
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary"
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
>
+339
View File
@@ -0,0 +1,339 @@
"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 } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
interface ICalImportModalProps {
calendars: Calendar[];
client: JMAPClient;
onClose: () => void;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
type ImportStep = "select" | "preview" | "importing";
export function ICalImportModal({ calendars, client, onClose }: 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 [step, setStep] = useState<ImportStep>("select");
const [parsedEvents, setParsedEvents] = useState<Partial<CalendarEvent>[]>([]);
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
const [calendarId, setCalendarId] = useState<string>(() => {
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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 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<CalendarEvent>): string => {
if (!event.start) return "";
try {
const date = parseISO(event.start);
return event.showWithoutTime
? format(date, "MMM d, yyyy")
: format(date, "MMM d, yyyy HH:mm");
} 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<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);
}, [step]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" 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-lg mx-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 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>
)}
{isParsing && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<p className="text-sm text-muted-foreground">{t("parsing")}</p>
</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>
)}
{step === "preview" && parsedEvents.length > 0 && (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t("parsed_events", { count: parsedEvents.length })}
</p>
<button
onClick={toggleAll}
className="text-xs text-primary hover:underline"
>
{selectedIndices.size === parsedEvents.length
? t("deselect_all")
: t("select_all")}
</button>
</div>
<div className="max-h-[300px] overflow-y-auto border border-border rounded-md divide-y divide-border">
{parsedEvents.map((event, index) => (
<label
key={index}
className="flex items-start gap-3 px-3 py-2.5 hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedIndices.has(index)}
onChange={() => toggleEvent(index)}
className="mt-0.5 rounded border-input"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{event.title || tCal("events.no_title")}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{formatEventDate(event)}
</span>
{event.recurrenceRules && event.recurrenceRules.length > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
<RefreshCw className="w-3 h-3" />
{event.recurrenceRules[0].frequency}
</span>
)}
</div>
</div>
</label>
))}
</div>
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">
{t("target_calendar")}
</label>
<select
value={calendarId}
onChange={(e) => setCalendarId(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"
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
</div>
)}
</>
)}
{step === "importing" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<p className="text-sm text-muted-foreground">{t("importing")}</p>
</div>
)}
</div>
{step !== "importing" && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{tForm("cancel")}
</Button>
{step === "preview" && (
<Button
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 mr-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
</div>
)}
</div>
</div>
);
}