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:
@@ -65,6 +65,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
|||||||
- Mini-calendar sidebar with calendar visibility toggles
|
- Mini-calendar sidebar with calendar visibility toggles
|
||||||
- Locale-aware date formatting (respects user's language)
|
- Locale-aware date formatting (respects user's language)
|
||||||
- Settings for first day of week, time format (12h/24h), and default view
|
- 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
|
- Real-time updates via JMAP push notifications
|
||||||
- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate)
|
- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate)
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -141,6 +141,8 @@ This document tracks the development status and planned features for JMAP Webmai
|
|||||||
- [x] Mobile touch targets (44px minimum)
|
- [x] Mobile touch targets (44px minimum)
|
||||||
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event)
|
||||||
- [x] i18n support with ICU pluralization (all 8 languages)
|
- [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
|
### Email Display
|
||||||
- [x] Proper email layout without horizontal scroll or clipping
|
- [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
|
### Advanced Features
|
||||||
- [ ] Email filters and rules
|
- [ ] Email filters and rules
|
||||||
- [ ] Calendar event drag-and-drop rescheduling
|
|
||||||
- [ ] Participant scheduling with iTIP invitations
|
- [ ] Participant scheduling with iTIP invitations
|
||||||
- [ ] Free/busy queries (Principal/getAvailability)
|
- [ ] Free/busy queries (Principal/getAvailability)
|
||||||
- [ ] iCalendar import via CalendarEvent/parse
|
|
||||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
||||||
|
- [ ] Calendar event notifications display
|
||||||
- [ ] Email templates
|
- [ ] Email templates
|
||||||
- [ ] Email encryption (PGP/GPG)
|
- [ ] Email encryption (PGP/GPG)
|
||||||
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
|||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal } from "@/components/calendar/event-modal";
|
||||||
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
@@ -37,6 +38,7 @@ export default function CalendarPage() {
|
|||||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||||
|
|
||||||
const [showEventModal, setShowEventModal] = useState(false);
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||||
@@ -295,6 +297,7 @@ export default function CalendarPage() {
|
|||||||
onToday={goToToday}
|
onToday={goToToday}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
onCreateEvent={() => openCreateModal()}
|
onCreateEvent={() => openCreateModal()}
|
||||||
|
onImport={() => setShowImportModal(true)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -330,6 +333,14 @@ export default function CalendarPage() {
|
|||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showImportModal && client && (
|
||||||
|
<ICalImportModal
|
||||||
|
calendars={calendars}
|
||||||
|
client={client}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"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 { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
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 {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -132,6 +135,57 @@ export function CalendarDayView({
|
|||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
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 (
|
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="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">
|
<div className="px-4 py-3 border-b border-border">
|
||||||
@@ -174,7 +228,14 @@ export function CalendarDayView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
@@ -211,6 +272,7 @@ export function CalendarDayView({
|
|||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={() => onSelectEvent(ev)}
|
||||||
|
draggable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -227,6 +289,21 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format,
|
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
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 {
|
interface CalendarMonthViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -92,6 +95,40 @@ export function CalendarMonthView({
|
|||||||
return result;
|
return result;
|
||||||
}, [days]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
<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">
|
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||||
@@ -121,10 +158,14 @@ export function CalendarMonthView({
|
|||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
aria-label={fullDateLabel}
|
aria-label={fullDateLabel}
|
||||||
onClick={() => onSelectDate(day)}
|
onClick={() => onSelectDate(day)}
|
||||||
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
||||||
|
onDragLeave={handleCellDragLeave}
|
||||||
|
onDrop={(e) => handleCellDrop(e, day)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
|
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
|
||||||
!inMonth && "bg-muted/30",
|
!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">
|
<div className="flex items-center justify-center mb-0.5">
|
||||||
@@ -150,6 +191,7 @@ export function CalendarMonthView({
|
|||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="chip"
|
variant="chip"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={() => onSelectEvent(ev)}
|
||||||
|
draggable
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -16,6 +16,7 @@ interface CalendarToolbarProps {
|
|||||||
onToday: () => void;
|
onToday: () => void;
|
||||||
onViewModeChange: (mode: CalendarViewMode) => void;
|
onViewModeChange: (mode: CalendarViewMode) => void;
|
||||||
onCreateEvent: () => void;
|
onCreateEvent: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ export function CalendarToolbar({
|
|||||||
onToday,
|
onToday,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
|
onImport,
|
||||||
isMobile,
|
isMobile,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
@@ -100,6 +102,13 @@ export function CalendarToolbar({
|
|||||||
</div>
|
</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}>
|
<Button size="sm" onClick={onCreateEvent}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{!isMobile && t("events.create")}
|
{!isMobile && t("events.create")}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useTranslations, useFormatter } from "next-intl";
|
||||||
import {
|
import {
|
||||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
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 {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -164,6 +167,59 @@ export function CalendarWeekView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
||||||
{hasAllDay && (
|
{hasAllDay && (
|
||||||
@@ -253,7 +309,15 @@ export function CalendarWeekView({
|
|||||||
const layouted = layoutOverlappingEvents(dayEvents);
|
const layouted = layoutOverlappingEvents(dayEvents);
|
||||||
|
|
||||||
return (
|
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) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
@@ -286,6 +350,7 @@ export function CalendarWeekView({
|
|||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={() => onSelectEvent(ev)}
|
||||||
|
draggable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -302,6 +367,21 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
@@ -11,6 +12,7 @@ interface EventCardProps {
|
|||||||
variant: "chip" | "block";
|
variant: "chip" | "block";
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
||||||
@@ -37,8 +39,24 @@ function parseDuration(duration: string): number {
|
|||||||
return totalMinutes;
|
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 t = useTranslations("calendar");
|
||||||
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
const startDate = parseISO(event.start);
|
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 timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`;
|
||||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
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") {
|
if (variant === "chip") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
|
"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",
|
"min-h-[44px] sm:min-h-0",
|
||||||
"hover:opacity-80 transition-opacity",
|
"hover:opacity-80 transition-opacity",
|
||||||
isSelected && "ring-2 ring-primary"
|
isSelected && "ring-2 ring-primary",
|
||||||
|
isBeingDragged && "opacity-50"
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}20`, color }}
|
style={{ backgroundColor: `${color}20`, color }}
|
||||||
>
|
>
|
||||||
@@ -74,10 +123,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected }: Eve
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"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 }}
|
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2059,6 +2059,36 @@ export class JMAPClient {
|
|||||||
throw new Error("Failed to update calendar event");
|
throw new Error("Failed to update calendar event");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||||||
|
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<void> {
|
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
|
||||||
const accountId = this.getCalendarsAccountId();
|
const accountId = this.getCalendarsAccountId();
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Termin aktualisiert",
|
"event_updated": "Termin aktualisiert",
|
||||||
"event_deleted": "Termin gelöscht",
|
"event_deleted": "Termin gelöscht",
|
||||||
"calendar_created": "Kalender erstellt",
|
"calendar_created": "Kalender erstellt",
|
||||||
"calendar_deleted": "Kalender gelöscht"
|
"calendar_deleted": "Kalender gelöscht",
|
||||||
|
"event_move_error": "Termin konnte nicht verschoben werden"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Kalender werden geladen...",
|
"loading_calendars": "Kalender werden geladen...",
|
||||||
"loading_events": "Termine werden geladen..."
|
"loading_events": "Termine werden geladen..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Zurück",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Erweiterte Suche",
|
"title": "Erweiterte Suche",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Event updated",
|
"event_updated": "Event updated",
|
||||||
"event_deleted": "Event deleted",
|
"event_deleted": "Event deleted",
|
||||||
"calendar_created": "Calendar created",
|
"calendar_created": "Calendar created",
|
||||||
"calendar_deleted": "Calendar deleted"
|
"calendar_deleted": "Calendar deleted",
|
||||||
|
"event_move_error": "Failed to move event"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Loading calendars...",
|
"loading_calendars": "Loading calendars...",
|
||||||
"loading_events": "Loading events..."
|
"loading_events": "Loading events..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Previous",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Advanced Search",
|
"title": "Advanced Search",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Evento actualizado",
|
"event_updated": "Evento actualizado",
|
||||||
"event_deleted": "Evento eliminado",
|
"event_deleted": "Evento eliminado",
|
||||||
"calendar_created": "Calendario creado",
|
"calendar_created": "Calendario creado",
|
||||||
"calendar_deleted": "Calendario eliminado"
|
"calendar_deleted": "Calendario eliminado",
|
||||||
|
"event_move_error": "Error al mover el evento"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Cargando calendarios...",
|
"loading_calendars": "Cargando calendarios...",
|
||||||
"loading_events": "Cargando eventos..."
|
"loading_events": "Cargando eventos..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Anterior",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Búsqueda avanzada",
|
"title": "Búsqueda avanzada",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Événement mis à jour",
|
"event_updated": "Événement mis à jour",
|
||||||
"event_deleted": "Événement supprimé",
|
"event_deleted": "Événement supprimé",
|
||||||
"calendar_created": "Calendrier créé",
|
"calendar_created": "Calendrier créé",
|
||||||
"calendar_deleted": "Calendrier supprimé"
|
"calendar_deleted": "Calendrier supprimé",
|
||||||
|
"event_move_error": "Échec du déplacement de l'événement"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Chargement des calendriers...",
|
"loading_calendars": "Chargement des calendriers...",
|
||||||
"loading_events": "Chargement des événements..."
|
"loading_events": "Chargement des événements..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Précédent",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Recherche avancée",
|
"title": "Recherche avancée",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Evento aggiornato",
|
"event_updated": "Evento aggiornato",
|
||||||
"event_deleted": "Evento eliminato",
|
"event_deleted": "Evento eliminato",
|
||||||
"calendar_created": "Calendario creato",
|
"calendar_created": "Calendario creato",
|
||||||
"calendar_deleted": "Calendario eliminato"
|
"calendar_deleted": "Calendario eliminato",
|
||||||
|
"event_move_error": "Spostamento dell'evento non riuscito"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Caricamento calendari...",
|
"loading_calendars": "Caricamento calendari...",
|
||||||
"loading_events": "Caricamento eventi..."
|
"loading_events": "Caricamento eventi..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Precedente",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Ricerca avanzata",
|
"title": "Ricerca avanzata",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "予定を更新しました",
|
"event_updated": "予定を更新しました",
|
||||||
"event_deleted": "予定を削除しました",
|
"event_deleted": "予定を削除しました",
|
||||||
"calendar_created": "カレンダーを作成しました",
|
"calendar_created": "カレンダーを作成しました",
|
||||||
"calendar_deleted": "カレンダーを削除しました"
|
"calendar_deleted": "カレンダーを削除しました",
|
||||||
|
"event_move_error": "イベントの移動に失敗しました"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "カレンダーを読み込み中...",
|
"loading_calendars": "カレンダーを読み込み中...",
|
||||||
"loading_events": "予定を読み込み中..."
|
"loading_events": "予定を読み込み中..."
|
||||||
},
|
},
|
||||||
"nav_prev": "前へ",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "詳細検索",
|
"title": "詳細検索",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Evenement bijgewerkt",
|
"event_updated": "Evenement bijgewerkt",
|
||||||
"event_deleted": "Evenement verwijderd",
|
"event_deleted": "Evenement verwijderd",
|
||||||
"calendar_created": "Agenda aangemaakt",
|
"calendar_created": "Agenda aangemaakt",
|
||||||
"calendar_deleted": "Agenda verwijderd"
|
"calendar_deleted": "Agenda verwijderd",
|
||||||
|
"event_move_error": "Evenement verplaatsen mislukt"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Agenda's laden...",
|
"loading_calendars": "Agenda's laden...",
|
||||||
"loading_events": "Evenementen laden..."
|
"loading_events": "Evenementen laden..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Vorige",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Geavanceerd zoeken",
|
"title": "Geavanceerd zoeken",
|
||||||
|
|||||||
+21
-2
@@ -1004,14 +1004,33 @@
|
|||||||
"event_updated": "Evento atualizado",
|
"event_updated": "Evento atualizado",
|
||||||
"event_deleted": "Evento excluído",
|
"event_deleted": "Evento excluído",
|
||||||
"calendar_created": "Calendário criado",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Carregando calendários...",
|
"loading_calendars": "Carregando calendários...",
|
||||||
"loading_events": "Carregando eventos..."
|
"loading_events": "Carregando eventos..."
|
||||||
},
|
},
|
||||||
"nav_prev": "Anterior",
|
"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": {
|
"advanced_search": {
|
||||||
"title": "Pesquisa avançada",
|
"title": "Pesquisa avançada",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface CalendarStore {
|
|||||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
|
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
|
||||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
||||||
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
|
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
|
||||||
|
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||||
setSelectedDate: (date: Date) => void;
|
setSelectedDate: (date: Date) => void;
|
||||||
setViewMode: (mode: CalendarViewMode) => void;
|
setViewMode: (mode: CalendarViewMode) => void;
|
||||||
toggleCalendarVisibility: (calendarId: string) => void;
|
toggleCalendarVisibility: (calendarId: string) => void;
|
||||||
@@ -114,6 +115,24 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
importEvents: async (client, events, calendarId) => {
|
||||||
|
let imported = 0;
|
||||||
|
for (const event of events) {
|
||||||
|
try {
|
||||||
|
const data: Partial<CalendarEvent> = {
|
||||||
|
...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) => {
|
deleteEvent: async (client, id) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user