diff --git a/README.md b/README.md index 21987bd3..586fc9f5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Mini-calendar sidebar with calendar visibility toggles - Locale-aware date formatting (respects user's language) - Settings for first day of week, time format (12h/24h), and default view +- Drag-and-drop rescheduling (week/day time snap, month date move) +- iCalendar (.ics) file import with event preview and bulk create - Real-time updates via JMAP push notifications - Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate) diff --git a/ROADMAP.md b/ROADMAP.md index 2c58adc5..98a1839c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,8 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Mobile touch targets (44px minimum) - [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event) - [x] i18n support with ICU pluralization (all 8 languages) +- [x] Drag-and-drop event rescheduling (week/day time snap, month date move) +- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create ### Email Display - [x] Proper email layout without horizontal scroll or clipping @@ -170,11 +172,10 @@ This document tracks the development status and planned features for JMAP Webmai ### Advanced Features - [ ] Email filters and rules -- [ ] Calendar event drag-and-drop rescheduling - [ ] Participant scheduling with iTIP invitations - [ ] Free/busy queries (Principal/getAvailability) -- [ ] iCalendar import via CalendarEvent/parse - [ ] Calendar sharing UI (JMAP Sharing RFC 9670) +- [ ] Calendar event notifications display - [ ] Email templates - [ ] Email encryption (PGP/GPG) - [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index ca0b71b2..44055266 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -21,6 +21,7 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; import { EventModal } from "@/components/calendar/event-modal"; +import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import type { CalendarEvent } from "@/lib/jmap/types"; export default function CalendarPage() { @@ -37,6 +38,7 @@ export default function CalendarPage() { const { firstDayOfWeek, timeFormat } = useSettingsStore(); const [showEventModal, setShowEventModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); const [miniMonth, setMiniMonth] = useState(new Date()); @@ -295,6 +297,7 @@ export default function CalendarPage() { onToday={goToToday} onViewModeChange={setViewMode} onCreateEvent={() => openCreateModal()} + onImport={() => setShowImportModal(true)} isMobile={isMobile} /> @@ -330,6 +333,14 @@ export default function CalendarPage() { onClose={() => { setShowEventModal(false); setEditEvent(null); }} /> )} + + {showImportModal && client && ( + setShowImportModal(false)} + /> + )} ); } diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d46533bd..ecaf09a1 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -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(null); + + const snapMinutes = useCallback((e: DragEvent): 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) => { + 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) => { + const related = e.relatedTarget as Node | null; + if (!e.currentTarget.contains(related)) setDropMinutes(null); + }, []); + + const handleDayDrop = useCallback(async (e: DragEvent) => { + 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 (
@@ -174,7 +228,14 @@ export function CalendarDayView({ ))}
-
+
{HOURS.map((h) => (
onSelectEvent(ev)} + draggable />
); @@ -227,6 +289,21 @@ export function CalendarDayView({
)} + + {dropMinutes !== null && ( +
+
+
+
+
+
+ {formatSnapTime(dropMinutes)} +
+
+ )}
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 8dec2291..9a8e9b89 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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(null); + + const handleCellDragOver = useCallback((e: DragEvent, 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) => { + const related = e.relatedTarget as Node | null; + if (!e.currentTarget.contains(related)) setDropDayKey(null); + }, []); + + const handleCellDrop = useCallback(async (e: DragEvent, 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 (
@@ -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" )} >
@@ -150,6 +191,7 @@ export function CalendarMonthView({ calendar={calendarMap.get(calId)} variant="chip" onClick={() => onSelectEvent(ev)} + draggable /> ); })} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index a8c92406..f02d4f36 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -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({
)} + {onImport && ( + + )} + +
+ +
+ {step === "select" && !isParsing && ( +
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" + }`} + > + +

{t("select_file")}

+

{t("drop_file")}

+

{t("supported_formats")}

+ +
+ )} + + {isParsing && ( +
+ +

{t("parsing")}

+
+ )} + + {error && ( +
+ {error} +
+ )} + + {step === "preview" && parsedEvents.length > 0 && ( + <> +
+

+ {t("parsed_events", { count: parsedEvents.length })} +

+ +
+ +
+ {parsedEvents.map((event, index) => ( + + ))} +
+ + {calendars.length > 1 && ( +
+ + +
+ )} + + )} + + {step === "importing" && ( +
+ +

{t("importing")}

+
+ )} +
+ + {step !== "importing" && ( +
+ + {step === "preview" && ( + + )} +
+ )} +
+ + ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 14c3841a..d2aa1ea9 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2059,6 +2059,36 @@ export class JMAPClient { throw new Error("Failed to update calendar event"); } + async parseCalendarEvents(accountId: string, blobId: string): Promise[]> { + const response = await this.request([ + ["CalendarEvent/parse", { + accountId, + blobIds: [blobId], + }, "0"] + ], this.calendarUsing()); + + if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") { + const result = response.methodResponses[0][1]; + + if (result.notParsable && result.notParsable.includes(blobId)) { + throw new Error("Invalid calendar file format"); + } + + if (result.notFound && result.notFound.includes(blobId)) { + throw new Error("Uploaded file not found"); + } + + const parsed = result.parsed?.[blobId]; + if (parsed) { + return Array.isArray(parsed) ? parsed : [parsed]; + } + + return []; + } + + throw new Error("Failed to parse calendar file"); + } + async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise { const accountId = this.getCalendarsAccountId(); diff --git a/locales/de/common.json b/locales/de/common.json index 430a488b..73ede7fc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Termin aktualisiert", "event_deleted": "Termin gelöscht", "calendar_created": "Kalender erstellt", - "calendar_deleted": "Kalender gelöscht" + "calendar_deleted": "Kalender gelöscht", + "event_move_error": "Termin konnte nicht verschoben werden" }, "status": { "loading_calendars": "Kalender werden geladen...", "loading_events": "Termine werden geladen..." }, "nav_prev": "Zurück", - "nav_next": "Weiter" + "nav_next": "Weiter", + "import": { + "title": "Kalender importieren", + "select_file": ".ics-Datei auswählen", + "drop_file": "oder Datei hier ablegen", + "supported_formats": "iCalendar (.ics) Dateien werden unterstützt", + "parsing": "Kalenderdatei wird analysiert...", + "parsed_events": "{count} Termine gefunden", + "no_events": "Keine Termine in der Datei gefunden", + "select_all": "Alle auswählen", + "deselect_all": "Alle abwählen", + "target_calendar": "In Kalender importieren", + "import_button": "Auswahl importieren", + "importing": "Termine werden importiert...", + "success": "{count} Termine erfolgreich importiert", + "error": "Kalender konnte nicht importiert werden", + "file_too_large": "Datei überschreitet das 5-MB-Limit", + "invalid_format": "Ungültiges Kalenderdateiformat" + } }, "advanced_search": { "title": "Erweiterte Suche", diff --git a/locales/en/common.json b/locales/en/common.json index e38c4d18..cb5abaf2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Event updated", "event_deleted": "Event deleted", "calendar_created": "Calendar created", - "calendar_deleted": "Calendar deleted" + "calendar_deleted": "Calendar deleted", + "event_move_error": "Failed to move event" }, "status": { "loading_calendars": "Loading calendars...", "loading_events": "Loading events..." }, "nav_prev": "Previous", - "nav_next": "Next" + "nav_next": "Next", + "import": { + "title": "Import Calendar", + "select_file": "Select .ics file", + "drop_file": "or drop file here", + "supported_formats": "Supports iCalendar (.ics) files", + "parsing": "Parsing calendar file...", + "parsed_events": "{count} events found", + "no_events": "No events found in file", + "select_all": "Select all", + "deselect_all": "Deselect all", + "target_calendar": "Import to calendar", + "import_button": "Import selected", + "importing": "Importing events...", + "success": "{count} events imported successfully", + "error": "Failed to import calendar", + "file_too_large": "File exceeds 5MB limit", + "invalid_format": "Invalid calendar file format" + } }, "advanced_search": { "title": "Advanced Search", diff --git a/locales/es/common.json b/locales/es/common.json index 669a389c..5eef978d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Evento actualizado", "event_deleted": "Evento eliminado", "calendar_created": "Calendario creado", - "calendar_deleted": "Calendario eliminado" + "calendar_deleted": "Calendario eliminado", + "event_move_error": "Error al mover el evento" }, "status": { "loading_calendars": "Cargando calendarios...", "loading_events": "Cargando eventos..." }, "nav_prev": "Anterior", - "nav_next": "Siguiente" + "nav_next": "Siguiente", + "import": { + "title": "Importar calendario", + "select_file": "Seleccionar archivo .ics", + "drop_file": "o arrastra el archivo aquí", + "supported_formats": "Archivos iCalendar (.ics) compatibles", + "parsing": "Analizando archivo de calendario...", + "parsed_events": "{count} eventos encontrados", + "no_events": "No se encontraron eventos en el archivo", + "select_all": "Seleccionar todo", + "deselect_all": "Deseleccionar todo", + "target_calendar": "Importar al calendario", + "import_button": "Importar selección", + "importing": "Importando eventos...", + "success": "{count} eventos importados correctamente", + "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" + } }, "advanced_search": { "title": "Búsqueda avanzada", diff --git a/locales/fr/common.json b/locales/fr/common.json index aac24f00..c007290b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Événement mis à jour", "event_deleted": "Événement supprimé", "calendar_created": "Calendrier créé", - "calendar_deleted": "Calendrier supprimé" + "calendar_deleted": "Calendrier supprimé", + "event_move_error": "Échec du déplacement de l'événement" }, "status": { "loading_calendars": "Chargement des calendriers...", "loading_events": "Chargement des événements..." }, "nav_prev": "Précédent", - "nav_next": "Suivant" + "nav_next": "Suivant", + "import": { + "title": "Importer un calendrier", + "select_file": "Sélectionner un fichier .ics", + "drop_file": "ou déposez le fichier ici", + "supported_formats": "Fichiers iCalendar (.ics) supportés", + "parsing": "Analyse du fichier en cours...", + "parsed_events": "{count} événements trouvés", + "no_events": "Aucun événement trouvé dans le fichier", + "select_all": "Tout sélectionner", + "deselect_all": "Tout désélectionner", + "target_calendar": "Importer dans le calendrier", + "import_button": "Importer la sélection", + "importing": "Importation en cours...", + "success": "{count} événements importés avec succès", + "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" + } }, "advanced_search": { "title": "Recherche avancée", diff --git a/locales/it/common.json b/locales/it/common.json index 1e657588..0992bb85 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Evento aggiornato", "event_deleted": "Evento eliminato", "calendar_created": "Calendario creato", - "calendar_deleted": "Calendario eliminato" + "calendar_deleted": "Calendario eliminato", + "event_move_error": "Spostamento dell'evento non riuscito" }, "status": { "loading_calendars": "Caricamento calendari...", "loading_events": "Caricamento eventi..." }, "nav_prev": "Precedente", - "nav_next": "Successivo" + "nav_next": "Successivo", + "import": { + "title": "Importa calendario", + "select_file": "Seleziona file .ics", + "drop_file": "o trascina il file qui", + "supported_formats": "File iCalendar (.ics) supportati", + "parsing": "Analisi del file in corso...", + "parsed_events": "{count} eventi trovati", + "no_events": "Nessun evento trovato nel file", + "select_all": "Seleziona tutto", + "deselect_all": "Deseleziona tutto", + "target_calendar": "Importa nel calendario", + "import_button": "Importa selezionati", + "importing": "Importazione eventi...", + "success": "{count} eventi importati con successo", + "error": "Importazione del calendario fallita", + "file_too_large": "Il file supera il limite di 5 MB", + "invalid_format": "Formato del file calendario non valido" + } }, "advanced_search": { "title": "Ricerca avanzata", diff --git a/locales/ja/common.json b/locales/ja/common.json index c2c79d0a..a84384db 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1004,14 +1004,33 @@ "event_updated": "予定を更新しました", "event_deleted": "予定を削除しました", "calendar_created": "カレンダーを作成しました", - "calendar_deleted": "カレンダーを削除しました" + "calendar_deleted": "カレンダーを削除しました", + "event_move_error": "イベントの移動に失敗しました" }, "status": { "loading_calendars": "カレンダーを読み込み中...", "loading_events": "予定を読み込み中..." }, "nav_prev": "前へ", - "nav_next": "次へ" + "nav_next": "次へ", + "import": { + "title": "カレンダーをインポート", + "select_file": ".icsファイルを選択", + "drop_file": "またはファイルをここにドロップ", + "supported_formats": "iCalendar (.ics) ファイルに対応", + "parsing": "カレンダーファイルを解析中...", + "parsed_events": "{count}件のイベントが見つかりました", + "no_events": "ファイルにイベントが見つかりません", + "select_all": "すべて選択", + "deselect_all": "すべて解除", + "target_calendar": "インポート先のカレンダー", + "import_button": "選択をインポート", + "importing": "イベントをインポート中...", + "success": "{count}件のイベントをインポートしました", + "error": "カレンダーのインポートに失敗しました", + "file_too_large": "ファイルサイズが5MBを超えています", + "invalid_format": "無効なカレンダーファイル形式" + } }, "advanced_search": { "title": "詳細検索", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3ff2c0e2..1407ac07 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Evenement bijgewerkt", "event_deleted": "Evenement verwijderd", "calendar_created": "Agenda aangemaakt", - "calendar_deleted": "Agenda verwijderd" + "calendar_deleted": "Agenda verwijderd", + "event_move_error": "Evenement verplaatsen mislukt" }, "status": { "loading_calendars": "Agenda's laden...", "loading_events": "Evenementen laden..." }, "nav_prev": "Vorige", - "nav_next": "Volgende" + "nav_next": "Volgende", + "import": { + "title": "Agenda importeren", + "select_file": "Selecteer .ics-bestand", + "drop_file": "of sleep het bestand hierheen", + "supported_formats": "iCalendar (.ics) bestanden worden ondersteund", + "parsing": "Agendabestand wordt verwerkt...", + "parsed_events": "{count} evenementen gevonden", + "no_events": "Geen evenementen gevonden in bestand", + "select_all": "Alles selecteren", + "deselect_all": "Alles deselecteren", + "target_calendar": "Importeren in agenda", + "import_button": "Selectie importeren", + "importing": "Evenementen importeren...", + "success": "{count} evenementen succesvol geïmporteerd", + "error": "Agenda importeren mislukt", + "file_too_large": "Bestand overschrijdt de limiet van 5 MB", + "invalid_format": "Ongeldig agendabestandsformaat" + } }, "advanced_search": { "title": "Geavanceerd zoeken", diff --git a/locales/pt/common.json b/locales/pt/common.json index e008551f..4b8dc2d9 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1004,14 +1004,33 @@ "event_updated": "Evento atualizado", "event_deleted": "Evento excluído", "calendar_created": "Calendário criado", - "calendar_deleted": "Calendário excluído" + "calendar_deleted": "Calendário excluído", + "event_move_error": "Falha ao mover o evento" }, "status": { "loading_calendars": "Carregando calendários...", "loading_events": "Carregando eventos..." }, "nav_prev": "Anterior", - "nav_next": "Próximo" + "nav_next": "Próximo", + "import": { + "title": "Importar calendário", + "select_file": "Selecionar arquivo .ics", + "drop_file": "ou arraste o arquivo aqui", + "supported_formats": "Arquivos iCalendar (.ics) suportados", + "parsing": "Analisando arquivo de calendário...", + "parsed_events": "{count} eventos encontrados", + "no_events": "Nenhum evento encontrado no arquivo", + "select_all": "Selecionar tudo", + "deselect_all": "Desmarcar tudo", + "target_calendar": "Importar para o calendário", + "import_button": "Importar selecionados", + "importing": "Importando eventos...", + "success": "{count} eventos importados com sucesso", + "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" + } }, "advanced_search": { "title": "Pesquisa avançada", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 0c5c5179..79634a3e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -25,6 +25,7 @@ interface CalendarStore { createEvent: (client: JMAPClient, event: Partial) => Promise; updateEvent: (client: JMAPClient, id: string, updates: Partial) => Promise; deleteEvent: (client: JMAPClient, id: string) => Promise; + importEvents: (client: JMAPClient, events: Partial[], calendarId: string) => Promise; setSelectedDate: (date: Date) => void; setViewMode: (mode: CalendarViewMode) => void; toggleCalendarVisibility: (calendarId: string) => void; @@ -114,6 +115,24 @@ export const useCalendarStore = create()( } }, + importEvents: async (client, events, calendarId) => { + let imported = 0; + for (const event of events) { + try { + const data: Partial = { + ...event, + calendarIds: { [calendarId]: true }, + }; + const created = await client.createCalendarEvent(data); + set((state) => ({ events: [...state.events, created] })); + imported++; + } catch (error) { + debug.error('Failed to import event:', event.title, error); + } + } + return imported; + }, + deleteEvent: async (client, id) => { set({ error: null }); try {