feat: add JMAP Calendar integration with month/week/day/agenda views
Full calendar support via JMAP Calendars (RFC 8984): - Event create/edit/delete with recurrence rules and reminders - Multi-day event spanning, column-based overlap layout - Locale-aware date formatting, first day of week and time format settings - Real-time updates via push notifications - ARIA accessibility, input validation, color sanitization - Keyboard shortcuts, mobile touch targets, focus trap - ICU pluralization for all 8 supported languages
This commit is contained in:
@@ -57,6 +57,17 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Bulk operations (multi-select, delete, group add, export)
|
||||
|
||||
### Calendar
|
||||
- JMAP Calendar integration (RFC 8984) with capability detection
|
||||
- Month, week, day, and agenda views
|
||||
- Event create, edit, and delete with recurrence rules and reminders
|
||||
- Multi-day events spanning across days, column-based overlap layout
|
||||
- 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
|
||||
- Real-time updates via JMAP push notifications
|
||||
- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate)
|
||||
|
||||
### Vacation Responder
|
||||
- JMAP VacationResponse management with date range scheduling
|
||||
- Dedicated settings tab with message configuration
|
||||
|
||||
+27
-1
@@ -120,6 +120,28 @@ This document tracks the development status and planned features for JMAP Webmai
|
||||
- [x] Sidebar indicator when vacation auto-reply is active
|
||||
- [x] i18n support (all 8 languages)
|
||||
|
||||
### Calendar Integration
|
||||
- [x] JMAP Calendar types (RFC 8984) and client methods
|
||||
- [x] Calendar capability detection (urn:ietf:params:jmap:calendars)
|
||||
- [x] Calendar store with Zustand (persist middleware)
|
||||
- [x] Month, week, day, and agenda views
|
||||
- [x] Event modal (create/edit/delete with recurrence, reminders)
|
||||
- [x] Mini-calendar sidebar with calendar visibility toggles
|
||||
- [x] Calendar settings (default view, week start, time format)
|
||||
- [x] Multi-day event spanning across all covered days
|
||||
- [x] Column-based overlap layout for concurrent events
|
||||
- [x] Locale-aware date formatting via next-intl
|
||||
- [x] First day of week and time format settings wired to views
|
||||
- [x] Push notification handling for calendar state changes
|
||||
- [x] Calendar page capability check (redirect if unsupported)
|
||||
- [x] Error handling with toast feedback on event CRUD
|
||||
- [x] Timezone auto-detection on event creation
|
||||
- [x] Input validation, color sanitization, focus trap
|
||||
- [x] ARIA grid roles and event card accessible labels
|
||||
- [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)
|
||||
|
||||
### Email Display
|
||||
- [x] Proper email layout without horizontal scroll or clipping
|
||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
||||
@@ -148,7 +170,11 @@ This document tracks the development status and planned features for JMAP Webmai
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Email filters and rules
|
||||
- [ ] Calendar integration (JMAP Calendars)
|
||||
- [ ] 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)
|
||||
- [ ] Email templates
|
||||
- [ ] Email encryption (PGP/GPG)
|
||||
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||
format,
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||
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 type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const isMobile = useIsMobile();
|
||||
const { client, isAuthenticated } = useAuthStore();
|
||||
const {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login");
|
||||
} else if (!supportsCalendar) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isAuthenticated, supportsCalendar, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const d = selectedDate;
|
||||
switch (viewMode) {
|
||||
case "month": {
|
||||
const ms = startOfMonth(d);
|
||||
const me = endOfMonth(d);
|
||||
return {
|
||||
start: format(startOfWeek(ms, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'00:00:00"),
|
||||
end: format(endOfWeek(me, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
case "week": {
|
||||
const ws = startOfWeek(d, { weekStartsOn: firstDayOfWeek });
|
||||
return {
|
||||
start: format(ws, "yyyy-MM-dd'T'00:00:00"),
|
||||
end: format(addDays(ws, 6), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
case "day":
|
||||
return {
|
||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
||||
end: format(d, "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
case "agenda":
|
||||
return {
|
||||
start: format(d, "yyyy-MM-dd'T'00:00:00"),
|
||||
end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
}, [selectedDate, viewMode, firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && calendars.length > 0) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, selectedCalendarIds, dateRange, fetchEvents]);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
case "month": next = subMonths(selectedDate, 1); break;
|
||||
case "week": next = subWeeks(selectedDate, 1); break;
|
||||
case "day": next = subDays(selectedDate, 1); break;
|
||||
case "agenda": next = subMonths(selectedDate, 1); break;
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const navigateNext = useCallback(() => {
|
||||
let next: Date;
|
||||
switch (viewMode) {
|
||||
case "month": next = addMonths(selectedDate, 1); break;
|
||||
case "week": next = addWeeks(selectedDate, 1); break;
|
||||
case "day": next = addDays(selectedDate, 1); break;
|
||||
case "agenda": next = addMonths(selectedDate, 1); break;
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
}, [viewMode, selectedDate, setSelectedDate]);
|
||||
|
||||
const goToToday = useCallback(() => {
|
||||
setSelectedDate(new Date());
|
||||
setMiniMonth(new Date());
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const handleSelectDate = useCallback((date: Date) => {
|
||||
setSelectedDate(date);
|
||||
setMiniMonth(date);
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const handleMiniMonthChange = useCallback((date: Date) => {
|
||||
setMiniMonth(date);
|
||||
setSelectedDate(date);
|
||||
}, [setSelectedDate]);
|
||||
|
||||
const openCreateModal = useCallback((date?: Date) => {
|
||||
setEditEvent(null);
|
||||
setDefaultModalDate(date || selectedDate);
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate]);
|
||||
|
||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||
setEditEvent(event);
|
||||
setDefaultModalDate(undefined);
|
||||
setShowEventModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
if (editEvent) {
|
||||
await updateEvent(client, editEvent.id, data);
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
const created = await createEvent(client, data);
|
||||
if (!created) {
|
||||
toast.error(t("notifications.event_error"));
|
||||
return;
|
||||
}
|
||||
toast.success(t("notifications.event_created"));
|
||||
}
|
||||
setShowEventModal(false);
|
||||
setEditEvent(null);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
||||
|
||||
const handleDeleteEvent = useCallback(async (id: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await deleteEvent(client, id);
|
||||
toast.success(t("notifications.event_deleted"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, deleteEvent, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
||||
if (showEventModal) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft": e.preventDefault(); navigatePrev(); break;
|
||||
case "ArrowRight": e.preventDefault(); navigateNext(); break;
|
||||
case "t": goToToday(); break;
|
||||
case "m": setViewMode("month"); break;
|
||||
case "w": setViewMode("week"); break;
|
||||
case "d": setViewMode("day"); break;
|
||||
case "a": setViewMode("agenda"); break;
|
||||
case "n": openCreateModal(); break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal]);
|
||||
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.filter((e) => {
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||
}),
|
||||
[events, selectedCalendarIds]
|
||||
);
|
||||
|
||||
if (!isAuthenticated || !supportsCalendar) return null;
|
||||
|
||||
const renderView = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
||||
<p className="text-sm">{t("status.loading_calendars")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewContent = (() => {
|
||||
switch (viewMode) {
|
||||
case "month":
|
||||
return (
|
||||
<CalendarMonthView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={openEditModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
/>
|
||||
);
|
||||
case "week":
|
||||
return (
|
||||
<CalendarWeekView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={openEditModal}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
case "day":
|
||||
return (
|
||||
<CalendarDayView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
onSelectEvent={openEditModal}
|
||||
onCreateAtTime={openCreateModal}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
case "agenda":
|
||||
return (
|
||||
<CalendarAgendaView
|
||||
selectedDate={selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={calendars}
|
||||
onSelectEvent={openEditModal}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
||||
{viewContent}
|
||||
{isLoadingEvents && calendars.length > 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
|
||||
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<CalendarToolbar
|
||||
selectedDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
onNavigateBack={() => router.push("/")}
|
||||
onPrev={navigatePrev}
|
||||
onNext={navigateNext}
|
||||
onToday={goToToday}
|
||||
onViewModeChange={setViewMode}
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
displayMonth={miniMonth}
|
||||
onSelectDate={handleSelectDate}
|
||||
onChangeMonth={handleMiniMonthChange}
|
||||
events={events}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{showEventModal && (
|
||||
<EventModal
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,12 @@ import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'advanced';
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,7 @@ export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||
const supportsCalendar = client?.supportsCalendars() ?? false;
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance') },
|
||||
@@ -30,6 +32,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account') },
|
||||
{ id: 'identities', label: t('tabs.identities') },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
|
||||
{ id: 'advanced', label: t('tabs.advanced') },
|
||||
];
|
||||
|
||||
@@ -89,6 +92,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'calendar' && <CalendarSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
||||
import { Calendar as CalendarIcon, MapPin } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarAgendaViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
interface DayGroup {
|
||||
date: Date;
|
||||
dateKey: string;
|
||||
events: CalendarEvent[];
|
||||
}
|
||||
|
||||
function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = new Date(event.start);
|
||||
if (!event.duration) return start;
|
||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
||||
return new Date(start.getTime() + totalMs);
|
||||
}
|
||||
|
||||
export function CalendarAgendaView({
|
||||
events,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
timeFormat = "24h",
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
calendars.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const sorted = [...events].sort((a, b) =>
|
||||
new Date(a.start).getTime() - new Date(b.start).getTime()
|
||||
);
|
||||
|
||||
const groups: DayGroup[] = [];
|
||||
const groupMap = new Map<string, DayGroup>();
|
||||
|
||||
sorted.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
const end = getEventEndDate(ev);
|
||||
const startKey = format(start, "yyyy-MM-dd");
|
||||
const endKey = format(end, "yyyy-MM-dd");
|
||||
|
||||
if (startKey === endKey || ev.showWithoutTime) {
|
||||
let group = groupMap.get(startKey);
|
||||
if (!group) {
|
||||
group = { date: start, dateKey: startKey, events: [] };
|
||||
groupMap.set(startKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.events.push(ev);
|
||||
} else {
|
||||
const cursor = new Date(start);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end);
|
||||
endDay.setHours(0, 0, 0, 0);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
let group = groupMap.get(key);
|
||||
if (!group) {
|
||||
group = { date: new Date(cursor), dateKey: key, events: [] };
|
||||
groupMap.set(key, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.events.push(ev);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
|
||||
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return groups;
|
||||
}, [events]);
|
||||
|
||||
const formatDateHeader = (date: Date): string => {
|
||||
if (isToday(date)) return t("events.today_header");
|
||||
if (isTomorrow(date)) return t("events.tomorrow_header");
|
||||
return intlFormatter.dateTime(date, { weekday: "long", month: "long", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
if (timeFormat === "12h") {
|
||||
return intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
return format(date, "HH:mm");
|
||||
};
|
||||
|
||||
if (grouped.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
|
||||
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("events.no_events")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.dateKey}>
|
||||
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isToday(group.date) && "text-primary"
|
||||
)}>
|
||||
{formatDateHeader(group.date)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{group.events.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const calendar = calendarMap.get(calId);
|
||||
const color = getEventColor(ev, calendar);
|
||||
const start = parseISO(ev.start);
|
||||
const durMin = parseDuration(ev.duration);
|
||||
const end = new Date(start.getTime() + durMin * 60000);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||
{ev.showWithoutTime ? (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("events.all_day")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-medium">{formatTime(start)}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatTime(end)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-1 self-stretch rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{ev.title || t("events.no_title")}
|
||||
</div>
|
||||
{locationName && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="truncate">{locationName}</span>
|
||||
</div>
|
||||
)}
|
||||
{calendar && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{calendar.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } 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";
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = new Date(event.start);
|
||||
if (!event.duration) return start;
|
||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
||||
return new Date(start.getTime() + totalMs);
|
||||
}
|
||||
|
||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||
const sorted = [...events].sort((a, b) => {
|
||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
const start = parseISO(event.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
||||
let placed = false;
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
if (columns[col].every(e => e.end <= startMin)) {
|
||||
columns[col].push({ event, end: endMin });
|
||||
result.push({ event, column: col, totalColumns: 0 });
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
columns.push([{ event, end: endMin }]);
|
||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const total = columns.length;
|
||||
result.forEach(r => r.totalColumns = total);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CalendarDayView({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
calendars.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const { timedEvents, allDayEvents } = useMemo(() => {
|
||||
const timed: CalendarEvent[] = [];
|
||||
const allDay: CalendarEvent[] = [];
|
||||
events.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
const end = getEventEndDate(ev);
|
||||
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
|
||||
const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||
if (!spansThisDay) return;
|
||||
|
||||
if (ev.showWithoutTime) allDay.push(ev);
|
||||
else timed.push(ev);
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const today = isToday(selectedDate);
|
||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||
const now = new Date();
|
||||
return now.getHours() * 60 + now.getMinutes();
|
||||
});
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes());
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
if (timeFormat === "12h") {
|
||||
const d = new Date(2000, 0, 1, h);
|
||||
return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||
};
|
||||
|
||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
||||
|
||||
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">
|
||||
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
|
||||
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-16 flex-shrink-0">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-xs text-muted-foreground text-right pr-3"
|
||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
||||
>
|
||||
{formatHour(h)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative border-l border-border" role="row">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
role="gridcell"
|
||||
aria-label={formatHour(h)}
|
||||
onClick={() => {
|
||||
const d = new Date(selectedDate);
|
||||
d.setHours(h, 0, 0, 0);
|
||||
onCreateAtTime(d);
|
||||
}}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10"
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="block"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{today && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectDate: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
firstDayOfWeek?: number;
|
||||
}
|
||||
|
||||
function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = new Date(event.start);
|
||||
if (!event.duration) return start;
|
||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
||||
return new Date(start.getTime() + totalMs);
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
onSelectDate,
|
||||
onSelectEvent,
|
||||
firstDayOfWeek = 1,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(selectedDate);
|
||||
const monthEnd = endOfMonth(selectedDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [selectedDate, weekStart]);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
calendars.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
events.forEach((e) => {
|
||||
try {
|
||||
const start = new Date(e.start);
|
||||
const end = getEventEndDate(e);
|
||||
const startDay = new Date(start);
|
||||
startDay.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end);
|
||||
endDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(e);
|
||||
map.set(key, arr);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const result: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
result.push(days.slice(i, i + 7));
|
||||
}
|
||||
return result;
|
||||
}, [days]);
|
||||
|
||||
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">
|
||||
{dayHeaders.map((d) => (
|
||||
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0">
|
||||
{t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row">
|
||||
{week.map((day) => {
|
||||
const inMonth = isSameMonth(day, selectedDate);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const maxVisible = 3;
|
||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
aria-selected={selected}
|
||||
aria-label={fullDateLabel}
|
||||
onClick={() => onSelectDate(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"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full",
|
||||
today && !selected && "bg-primary text-primary-foreground font-bold",
|
||||
selected && "bg-primary text-primary-foreground font-bold",
|
||||
!inMonth && !selected && !today && "text-muted-foreground/50",
|
||||
inMonth && !selected && !today && "font-medium"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, maxVisible).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > maxVisible && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">
|
||||
{t("events.more", { count: dayEvents.length - maxVisible })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarPanel({
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
if (calendars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{calendars.map((cal) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cal.id}
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded text-sm transition-colors",
|
||||
"hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
viewMode: CalendarViewMode;
|
||||
onNavigateBack: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToday: () => void;
|
||||
onViewModeChange: (mode: CalendarViewMode) => void;
|
||||
onCreateEvent: () => void;
|
||||
isMobile?: boolean;
|
||||
firstDayOfWeek?: number;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
selectedDate,
|
||||
viewMode,
|
||||
onNavigateBack,
|
||||
onPrev,
|
||||
onNext,
|
||||
onToday,
|
||||
onViewModeChange,
|
||||
onCreateEvent,
|
||||
isMobile,
|
||||
firstDayOfWeek = 1,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
||||
|
||||
const getDateLabel = (): string => {
|
||||
switch (viewMode) {
|
||||
case "month":
|
||||
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
case "week": {
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
||||
const we = addDays(ws, 6);
|
||||
const sameMonth = ws.getMonth() === we.getMonth();
|
||||
if (sameMonth) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
||||
}
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
||||
}
|
||||
case "day":
|
||||
return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
case "agenda":
|
||||
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={onNavigateBack} className="mr-1">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
{!isMobile && t("back_to_email")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium min-w-[140px] text-center">
|
||||
{getDateLabel()}
|
||||
</span>
|
||||
<button onClick={onNext} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_next")}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onToday}>
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{!isMobile && (
|
||||
<div className="flex border border-border rounded-md overflow-hidden">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onViewModeChange(v)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
v === viewMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(`views.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button size="sm" onClick={onCreateEvent}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{!isMobile && t("events.create")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import {
|
||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarWeekViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectDate: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = new Date(event.start);
|
||||
if (!event.duration) return start;
|
||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
||||
return new Date(start.getTime() + totalMs);
|
||||
}
|
||||
|
||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||
const sorted = [...events].sort((a, b) => {
|
||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
const start = parseISO(event.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
||||
let placed = false;
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
if (columns[col].every(e => e.end <= startMin)) {
|
||||
columns[col].push({ event, end: endMin });
|
||||
result.push({ event, column: col, totalColumns: 0 });
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
columns.push([{ event, end: endMin }]);
|
||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const total = columns.length;
|
||||
result.forEach(r => r.totalColumns = total);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CalendarWeekView({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
onSelectDate,
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
timeFormat = "24h",
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
}, [selectedDate, weekStart]);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
calendars.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const { timedEvents, allDayEvents } = useMemo(() => {
|
||||
const timed: Map<string, CalendarEvent[]> = new Map();
|
||||
const allDay: Map<string, CalendarEvent[]> = new Map();
|
||||
|
||||
events.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
const end = getEventEndDate(ev);
|
||||
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
||||
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
const key = format(cursor, "yyyy-MM-dd");
|
||||
if (ev.showWithoutTime) {
|
||||
const arr = allDay.get(key) || [];
|
||||
arr.push(ev);
|
||||
allDay.set(key, arr);
|
||||
} else {
|
||||
const arr = timed.get(key) || [];
|
||||
arr.push(ev);
|
||||
timed.set(key, arr);
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events]);
|
||||
|
||||
const hasAllDay = useMemo(() => {
|
||||
return weekDays.some(day => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
return (allDayEvents.get(key) || []).length > 0;
|
||||
});
|
||||
}, [weekDays, allDayEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
const scrollTo = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||
scrollRef.current.scrollTop = scrollTo;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||
const now = new Date();
|
||||
return now.getHours() * 60 + now.getMinutes();
|
||||
});
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes());
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleSlotClick = (day: Date, hour: number) => {
|
||||
const d = new Date(day);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
onCreateAtTime(d);
|
||||
};
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
if (timeFormat === "12h") {
|
||||
const d = new Date(2000, 0, 1, h);
|
||||
return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
||||
{hasAllDay && (
|
||||
<div className="flex border-b border-border">
|
||||
<div className="w-14 flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right">
|
||||
{t("events.all_day")}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-7 gap-px bg-border">
|
||||
{weekDays.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayAllDay = allDayEvents.get(key) || [];
|
||||
return (
|
||||
<div key={key} className="bg-background p-0.5 min-h-[28px]">
|
||||
{dayAllDay.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex border-b border-border" role="row">
|
||||
<div className="w-14 flex-shrink-0" />
|
||||
<div className="flex-1 grid grid-cols-7 border-l border-border">
|
||||
{weekDays.map((day) => {
|
||||
const todayCol = isToday(day);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => onSelectDate(day)}
|
||||
role="columnheader"
|
||||
aria-label={fullLabel}
|
||||
className={cn(
|
||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors",
|
||||
"hover:bg-muted/50",
|
||||
todayCol && "font-bold",
|
||||
)}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground uppercase">
|
||||
{intlFormatter.dateTime(day, { weekday: "short" })}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"inline-flex items-center justify-center w-7 h-7 rounded-full text-sm",
|
||||
todayCol && "bg-primary text-primary-foreground",
|
||||
selected && !todayCol && "bg-accent text-accent-foreground"
|
||||
)}>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-14 flex-shrink-0">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-[10px] text-muted-foreground text-right pr-2"
|
||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
||||
>
|
||||
{formatHour(h)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-7 border-l border-border relative">
|
||||
{weekDays.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = timedEvents.get(key) || [];
|
||||
const todayCol = isToday(day);
|
||||
const layouted = layoutOverlappingEvents(dayEvents);
|
||||
|
||||
return (
|
||||
<div key={key} className="relative border-r border-border last:border-r-0" role="row">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
role="gridcell"
|
||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||
onClick={() => handleSlotClick(day, h)}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10"
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="block"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{todayCol && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendar?: Calendar;
|
||||
variant: "chip" | "block";
|
||||
onClick?: () => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
||||
if (!color) return fallback;
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(color)) return color;
|
||||
if (/^(rgb|hsl)a?\([\d\s,.%/]+\)$/.test(color)) return color;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
|
||||
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
|
||||
}
|
||||
|
||||
function parseDuration(duration: string): number {
|
||||
let totalMinutes = 0;
|
||||
const weekMatch = duration.match(/(\d+)W/);
|
||||
const hourMatch = duration.match(/(\d+)H/);
|
||||
const minMatch = duration.match(/(\d+)M/);
|
||||
const dayMatch = duration.match(/(\d+)D/);
|
||||
if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60;
|
||||
if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60;
|
||||
if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60;
|
||||
if (minMatch) totalMinutes += parseInt(minMatch[1]);
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, isSelected }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const color = getEventColor(event, calendar);
|
||||
const startDate = parseISO(event.start);
|
||||
|
||||
const calendarName = calendar?.name || "";
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||
const timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
|
||||
|
||||
if (variant === "chip") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||
aria-label={ariaLabel}
|
||||
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"
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="truncate">{event.title || t("events.no_title")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||
aria-label={ariaLabel}
|
||||
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"
|
||||
)}
|
||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
|
||||
>
|
||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
||||
{durationMinutes > 30 && (
|
||||
<div className="opacity-80 text-[10px]">
|
||||
{timeString}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export { parseDuration, getEventColor, sanitizeColor };
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import { format, parseISO, addHours } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { parseDuration } from "./event-card";
|
||||
|
||||
interface EventModalProps {
|
||||
event?: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
onSave: (data: Partial<CalendarEvent>) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date): string {
|
||||
return format(d, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
function formatTimeInput(d: Date): string {
|
||||
return format(d, "HH:mm");
|
||||
}
|
||||
|
||||
function buildDuration(startDate: Date, endDate: Date): string {
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
const days = Math.floor(totalMinutes / (24 * 60));
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
let dur = "P";
|
||||
if (days > 0) dur += `${days}D`;
|
||||
dur += "T";
|
||||
if (hours > 0) dur += `${hours}H`;
|
||||
if (minutes > 0) dur += `${minutes}M`;
|
||||
if (dur === "PT") dur = "PT0M";
|
||||
return dur;
|
||||
}
|
||||
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||
|
||||
export function EventModal({
|
||||
event,
|
||||
calendars,
|
||||
defaultDate,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!event;
|
||||
|
||||
const getInitialStart = (): Date => {
|
||||
if (event?.start) return parseISO(event.start);
|
||||
if (defaultDate) {
|
||||
const d = new Date(defaultDate);
|
||||
const now = new Date();
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const getInitialEnd = (): Date => {
|
||||
if (event?.start) {
|
||||
const s = parseISO(event.start);
|
||||
const dur = parseDuration(event.duration);
|
||||
return new Date(s.getTime() + dur * 60000);
|
||||
}
|
||||
return addHours(getInitialStart(), 1);
|
||||
};
|
||||
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
const [location, setLocation] = useState(
|
||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||
);
|
||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || "";
|
||||
const defaultCal = calendars.find(c => c.isDefault);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
||||
if (!event?.recurrenceRules?.length) return "none";
|
||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
||||
});
|
||||
const [alert, setAlert] = useState<AlertOption>(() => {
|
||||
if (!event?.alerts) return "none";
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return "none";
|
||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return "at_time";
|
||||
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||
if (minMatch) return minMatch[1] as AlertOption;
|
||||
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||
}
|
||||
return "none";
|
||||
});
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return;
|
||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||
|
||||
const startStr = allDay
|
||||
? `${startDate}T00:00:00`
|
||||
: `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay
|
||||
? `${endDate}T23:59:59`
|
||||
: `${endDate}T${endTime}:00`;
|
||||
|
||||
const start = new Date(startStr);
|
||||
let end = new Date(endStr);
|
||||
|
||||
if (end <= start) {
|
||||
end = new Date(start.getTime() + 3600000);
|
||||
}
|
||||
|
||||
const duration = allDay
|
||||
? `P${Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))}D`
|
||||
: buildDuration(start, end);
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
start: startStr,
|
||||
duration,
|
||||
timeZone,
|
||||
showWithoutTime: allDay,
|
||||
calendarIds: { [calendarId]: true },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: "busy",
|
||||
privacy: "public",
|
||||
};
|
||||
|
||||
if (location.trim()) {
|
||||
data.locations = {
|
||||
loc1: {
|
||||
"@type": "Location",
|
||||
name: location.trim(),
|
||||
description: null,
|
||||
locationTypes: null,
|
||||
coordinates: null,
|
||||
timeZone: null,
|
||||
links: null,
|
||||
relativeTo: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (recurrence !== "none") {
|
||||
data.recurrenceRules = [{
|
||||
"@type": "RecurrenceRule",
|
||||
frequency: recurrence,
|
||||
interval: 1,
|
||||
rscale: "gregorian",
|
||||
skip: "omit",
|
||||
firstDayOfWeek: "mo",
|
||||
byDay: null,
|
||||
byMonthDay: null,
|
||||
byMonth: null,
|
||||
byYearDay: null,
|
||||
byWeekNo: null,
|
||||
byHour: null,
|
||||
byMinute: null,
|
||||
bySecond: null,
|
||||
bySetPosition: null,
|
||||
count: null,
|
||||
until: null,
|
||||
}];
|
||||
}
|
||||
|
||||
if (alert !== "none") {
|
||||
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
|
||||
data.alerts = {
|
||||
alert1: {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onSave(data);
|
||||
}, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, onSave]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, handleSave]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstEl = focusableEls[0];
|
||||
const lastEl = focusableEls[focusableEls.length - 1];
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
if (e.shiftKey && document.activeElement === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl?.focus();
|
||||
}
|
||||
};
|
||||
modal.addEventListener("keydown", handler);
|
||||
firstEl?.focus();
|
||||
return () => modal.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
||||
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={isEdit ? t("events.edit") : t("events.create")} 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">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("form.title")}
|
||||
maxLength={500}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.description")}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("form.description")}
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.location")}</label>
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder={t("form.location")}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(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"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(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"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{calendars.length > 1 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</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>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
||||
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"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
<select
|
||||
value={alert}
|
||||
onChange={(e) => setAlert(e.target.value as AlertOption)}
|
||||
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"
|
||||
>
|
||||
<option value="none">{t("alerts.none")}</option>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
|
||||
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
|
||||
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
|
||||
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
|
||||
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
||||
{isEdit && onDelete ? (
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!title.trim()}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
interface MiniCalendarProps {
|
||||
selectedDate: Date;
|
||||
displayMonth: Date;
|
||||
onSelectDate: (date: Date) => void;
|
||||
onChangeMonth: (date: Date) => void;
|
||||
events?: CalendarEvent[];
|
||||
firstDayOfWeek?: number;
|
||||
}
|
||||
|
||||
export function MiniCalendar({
|
||||
selectedDate,
|
||||
displayMonth,
|
||||
onSelectDate,
|
||||
onChangeMonth,
|
||||
events = [],
|
||||
firstDayOfWeek = 1,
|
||||
}: MiniCalendarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [displayMonth, weekStart]);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
events.forEach(e => {
|
||||
try { set.add(format(new Date(e.start), "yyyy-MM-dd")); } catch { /* skip */ }
|
||||
});
|
||||
return set;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
const currentYear = getYear(displayMonth);
|
||||
const currentMonth = getMonth(displayMonth);
|
||||
const decadeStart = Math.floor(currentYear / 10) * 10;
|
||||
const years = Array.from({ length: 12 }, (_, i) => decadeStart - 1 + i);
|
||||
|
||||
const handlePickMonth = (month: number) => {
|
||||
onChangeMonth(setMonth(displayMonth, month));
|
||||
setPickerView("days");
|
||||
};
|
||||
|
||||
const handlePickYear = (year: number) => {
|
||||
onChangeMonth(setYear(displayMonth, year));
|
||||
setPickerView("months");
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (pickerView === "days") onChangeMonth(subMonths(displayMonth, 1));
|
||||
else if (pickerView === "months") onChangeMonth(subYears(displayMonth, 1));
|
||||
else onChangeMonth(setYear(displayMonth, decadeStart - 10));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (pickerView === "days") onChangeMonth(addMonths(displayMonth, 1));
|
||||
else if (pickerView === "months") onChangeMonth(addYears(displayMonth, 1));
|
||||
else onChangeMonth(setYear(displayMonth, decadeStart + 10));
|
||||
};
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (pickerView === "days") setPickerView("months");
|
||||
else if (pickerView === "months") setPickerView("years");
|
||||
};
|
||||
|
||||
const headerLabel =
|
||||
pickerView === "days"
|
||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
||||
: pickerView === "months"
|
||||
? String(currentYear)
|
||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_prev")}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHeaderClick}
|
||||
disabled={pickerView === "years"}
|
||||
className={cn(
|
||||
"text-sm font-medium px-1 rounded transition-colors",
|
||||
pickerView !== "years" && "hover:bg-muted cursor-pointer",
|
||||
pickerView === "years" && "cursor-default"
|
||||
)}
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_next")}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pickerView === "days" && (
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
{dayHeaders.map((d) => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||
{t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => onSelectDate(day)}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
||||
!inMonth && "text-muted-foreground/40",
|
||||
inMonth && !selected && "hover:bg-muted",
|
||||
today && !selected && "font-bold text-primary",
|
||||
selected && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
{hasEvent && !selected && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerView === "months" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
{MONTH_LABELS.map((label, i) => {
|
||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePickMonth(i)}
|
||||
className={cn(
|
||||
"py-2 text-xs rounded-md transition-colors",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && isCurrentMonth && "font-bold text-primary",
|
||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerView === "years" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
{years.map((year) => {
|
||||
const inDecade = year >= decadeStart && year <= decadeStart + 9;
|
||||
const isCurrentYear = year === getYear(new Date());
|
||||
const isSelected = year === getYear(selectedDate);
|
||||
return (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => handlePickYear(year)}
|
||||
className={cn(
|
||||
"py-2 text-xs rounded-md transition-colors",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && isCurrentYear && "font-bold text-primary",
|
||||
!isSelected && !isCurrentYear && !inDecade && "text-muted-foreground/40",
|
||||
!isSelected && !isCurrentYear && "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
BookUser,
|
||||
Palmtree,
|
||||
SlidersHorizontal,
|
||||
Calendar,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
@@ -36,6 +37,7 @@ import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -297,6 +299,7 @@ export function Sidebar({
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const t = useTranslations('sidebar');
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
|
||||
// Sync local search query with store's active search query
|
||||
useEffect(() => {
|
||||
@@ -525,6 +528,20 @@ export function Sidebar({
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Calendar */}
|
||||
{supportsCalendar && (
|
||||
<button
|
||||
onClick={() => router.push('/calendar')}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{t("calendar")}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => router.push('/settings')}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const t = useTranslations('calendar.settings');
|
||||
const tViews = useTranslations('calendar.views');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const { timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')}>
|
||||
<SettingItem label={t('default_view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(value) => setViewMode(value as CalendarViewMode)}
|
||||
options={[
|
||||
{ value: 'month', label: tViews('month') },
|
||||
{ value: 'week', label: tViews('week') },
|
||||
{ value: 'day', label: tViews('day') },
|
||||
{ value: 'agenda', label: tViews('agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('week_starts_on')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format_12h') },
|
||||
{ value: '24h', label: t('time_format_24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
+339
-28
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -1594,15 +1594,28 @@ export class JMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:contacts");
|
||||
}
|
||||
|
||||
supportsCalendars(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:calendars");
|
||||
}
|
||||
|
||||
getContactsAccountId(): string {
|
||||
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
|
||||
return contactsAccount || this.accountId;
|
||||
}
|
||||
|
||||
getCalendarsAccountId(): string {
|
||||
const calendarsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:calendars"];
|
||||
return calendarsAccount || this.accountId;
|
||||
}
|
||||
|
||||
private contactUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||
}
|
||||
|
||||
private calendarUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||||
}
|
||||
|
||||
async getAddressBooks(): Promise<AddressBook[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
@@ -1798,6 +1811,282 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getCalendars(): Promise<Calendar[]> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["Calendar/get", { accountId }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||
return (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendars:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
create: {
|
||||
"new-calendar": calendar
|
||||
}
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notCreated?.["new-calendar"]) {
|
||||
const error = result.notCreated["new-calendar"];
|
||||
throw new Error(error.description || "Failed to create calendar");
|
||||
}
|
||||
|
||||
const createdId = result.created?.["new-calendar"]?.id;
|
||||
if (createdId) {
|
||||
const calendars = await this.getCalendars();
|
||||
const created = calendars.find(c => c.id === createdId);
|
||||
if (created) return created;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create calendar");
|
||||
}
|
||||
|
||||
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[calendarId]: updates
|
||||
}
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notUpdated?.[calendarId]) {
|
||||
const error = result.notUpdated[calendarId];
|
||||
throw new Error(error.description || "Failed to update calendar");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update calendar");
|
||||
}
|
||||
|
||||
async deleteCalendar(calendarId: string): Promise<void> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
destroy: [calendarId]
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notDestroyed?.[calendarId]) {
|
||||
const error = result.notDestroyed[calendarId];
|
||||
throw new Error(error.description || "Failed to delete calendar");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to delete calendar");
|
||||
}
|
||||
|
||||
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||||
if (calendarIds && calendarIds.length > 0) {
|
||||
queryArgs.filter = { inCalendars: calendarIds };
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendar events:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async queryCalendarEvents(
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter,
|
||||
limit: limit || 100,
|
||||
};
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/query", queryArgs, "0"],
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
||||
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to query calendar events:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
ids: [id],
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
return list[0] || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendar event:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
create: {
|
||||
"new-event": event
|
||||
}
|
||||
};
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", setArgs, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notCreated?.["new-event"]) {
|
||||
const error = result.notCreated["new-event"];
|
||||
throw new Error(error.description || "Failed to create calendar event");
|
||||
}
|
||||
|
||||
const createdId = result.created?.["new-event"]?.id;
|
||||
if (createdId) {
|
||||
const created = await this.getCalendarEvent(createdId);
|
||||
if (created) return created;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create calendar event");
|
||||
}
|
||||
|
||||
async updateCalendarEvent(
|
||||
eventId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
sendSchedulingMessages?: boolean
|
||||
): Promise<void> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
update: {
|
||||
[eventId]: updates
|
||||
}
|
||||
};
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", setArgs, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notUpdated?.[eventId]) {
|
||||
const error = result.notUpdated[eventId];
|
||||
throw new Error(error.description || "Failed to update calendar event");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update calendar event");
|
||||
}
|
||||
|
||||
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise<void> {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
destroy: [eventId]
|
||||
};
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", setArgs, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notDestroyed?.[eventId]) {
|
||||
const error = result.notDestroyed[eventId];
|
||||
throw new Error(error.description || "Failed to delete calendar event");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to delete calendar event");
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
|
||||
@@ -1851,25 +2140,32 @@ export class JMAPClient {
|
||||
|
||||
private async fetchCurrentStates(): Promise<void> {
|
||||
try {
|
||||
// Get current states from server using JMAP query
|
||||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
||||
const methodCalls: JMAPMethodCall[] = [
|
||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
||||
];
|
||||
|
||||
if (this.supportsCalendars()) {
|
||||
using.push('urn:ietf:params:jmap:calendars');
|
||||
const calAccountId = this.getCalendarsAccountId();
|
||||
methodCalls.push(
|
||||
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
|
||||
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
||||
],
|
||||
}),
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Extract states from response
|
||||
for (const [method, result] of data.methodResponses) {
|
||||
if (method === 'Mailbox/get' && result.state) {
|
||||
this.pollingStates['Mailbox'] = result.state;
|
||||
@@ -1877,6 +2173,12 @@ export class JMAPClient {
|
||||
if (method === 'Email/get' && result.state) {
|
||||
this.pollingStates['Email'] = result.state;
|
||||
}
|
||||
if (method === 'Calendar/get' && result.state) {
|
||||
this.pollingStates['Calendar'] = result.state;
|
||||
}
|
||||
if (method === 'CalendarEvent/get' && result.state) {
|
||||
this.pollingStates['CalendarEvent'] = result.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1886,19 +2188,28 @@ export class JMAPClient {
|
||||
|
||||
private async checkForStateChanges(): Promise<void> {
|
||||
try {
|
||||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
||||
const methodCalls: JMAPMethodCall[] = [
|
||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
||||
];
|
||||
|
||||
if (this.supportsCalendars()) {
|
||||
using.push('urn:ietf:params:jmap:calendars');
|
||||
const calAccountId = this.getCalendarsAccountId();
|
||||
methodCalls.push(
|
||||
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
|
||||
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||
methodCalls: [
|
||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
||||
],
|
||||
}),
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -1907,19 +2218,19 @@ export class JMAPClient {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [method, result] of data.methodResponses) {
|
||||
if (method === 'Mailbox/get' && result.state) {
|
||||
if (this.pollingStates['Mailbox'] && this.pollingStates['Mailbox'] !== result.state) {
|
||||
changes['Mailbox'] = result.state;
|
||||
const typeMap: Record<string, string> = {
|
||||
'Mailbox/get': 'Mailbox',
|
||||
'Email/get': 'Email',
|
||||
'Calendar/get': 'Calendar',
|
||||
'CalendarEvent/get': 'CalendarEvent',
|
||||
};
|
||||
const stateKey = typeMap[method];
|
||||
if (stateKey && result.state) {
|
||||
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
|
||||
changes[stateKey] = result.state;
|
||||
hasChanges = true;
|
||||
}
|
||||
this.pollingStates['Mailbox'] = result.state;
|
||||
}
|
||||
if (method === 'Email/get' && result.state) {
|
||||
if (this.pollingStates['Email'] && this.pollingStates['Email'] !== result.state) {
|
||||
changes['Email'] = result.state;
|
||||
hasChanges = true;
|
||||
}
|
||||
this.pollingStates['Email'] = result.state;
|
||||
this.pollingStates[stateKey] = result.state;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,224 @@ export interface DeliveryStatus {
|
||||
displayed: "unknown" | "yes";
|
||||
}
|
||||
|
||||
// JMAP Calendar Types (RFC 8984 JSCalendar + RFC 9553 JMAP Calendars)
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
isSubscribed: boolean;
|
||||
isVisible: boolean;
|
||||
isDefault: boolean;
|
||||
includeInAvailability: 'all' | 'attending' | 'none';
|
||||
defaultAlertsWithTime: Record<string, CalendarEventAlert> | null;
|
||||
defaultAlertsWithoutTime: Record<string, CalendarEventAlert> | null;
|
||||
timeZone: string | null;
|
||||
shareWith: Record<string, CalendarRights> | null;
|
||||
myRights: CalendarRights;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy: boolean;
|
||||
mayReadItems: boolean;
|
||||
mayWriteAll: boolean;
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayAdmin: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
calendarIds: Record<string, boolean>;
|
||||
isDraft: boolean;
|
||||
isOrigin: boolean;
|
||||
utcStart: string | null;
|
||||
utcEnd: string | null;
|
||||
'@type': 'Event';
|
||||
uid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionContentType: string;
|
||||
created: string | null;
|
||||
updated: string;
|
||||
sequence: number;
|
||||
start: string;
|
||||
duration: string;
|
||||
timeZone: string | null;
|
||||
showWithoutTime: boolean;
|
||||
status: 'tentative' | 'confirmed' | 'cancelled';
|
||||
freeBusyStatus: 'free' | 'busy';
|
||||
privacy: 'public' | 'private' | 'secret';
|
||||
color: string | null;
|
||||
keywords: Record<string, boolean> | null;
|
||||
categories: Record<string, boolean> | null;
|
||||
locale: string | null;
|
||||
replyTo: Record<string, string> | null;
|
||||
participants: Record<string, CalendarParticipant> | null;
|
||||
mayInviteSelf: boolean;
|
||||
mayInviteOthers: boolean;
|
||||
hideAttendees: boolean;
|
||||
recurrenceId: string | null;
|
||||
recurrenceIdTimeZone: string | null;
|
||||
recurrenceRules: CalendarRecurrenceRule[] | null;
|
||||
recurrenceOverrides: Record<string, Partial<CalendarEvent>> | null;
|
||||
excludedRecurrenceRules: CalendarRecurrenceRule[] | null;
|
||||
useDefaultAlerts: boolean;
|
||||
alerts: Record<string, CalendarEventAlert> | null;
|
||||
locations: Record<string, CalendarLocation> | null;
|
||||
virtualLocations: Record<string, CalendarVirtualLocation> | null;
|
||||
links: Record<string, CalendarLink> | null;
|
||||
relatedTo: Record<string, CalendarRelation> | null;
|
||||
}
|
||||
|
||||
export interface CalendarParticipant {
|
||||
'@type': 'Participant';
|
||||
name: string;
|
||||
email: string;
|
||||
description: string | null;
|
||||
sendTo: Record<string, string> | null;
|
||||
kind: 'individual' | 'group' | 'location' | 'resource';
|
||||
roles: Record<string, boolean>;
|
||||
participationStatus: 'accepted' | 'declined' | 'tentative' | 'delegated' | 'needs-action';
|
||||
participationComment: string | null;
|
||||
expectReply: boolean;
|
||||
scheduleAgent: 'server' | 'client' | 'none';
|
||||
scheduleForceSend: boolean;
|
||||
scheduleId: string | null;
|
||||
scheduleSequence: number;
|
||||
scheduleStatus: string[] | null;
|
||||
scheduleUpdated: string | null;
|
||||
invitedBy: string | null;
|
||||
delegatedTo: Record<string, boolean> | null;
|
||||
delegatedFrom: Record<string, boolean> | null;
|
||||
memberOf: Record<string, boolean> | null;
|
||||
locationId: string | null;
|
||||
language: string | null;
|
||||
links: Record<string, CalendarLink> | null;
|
||||
}
|
||||
|
||||
export interface CalendarRecurrenceRule {
|
||||
'@type': 'RecurrenceRule';
|
||||
frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly' | 'minutely' | 'secondly';
|
||||
interval: number;
|
||||
rscale: string;
|
||||
skip: 'omit' | 'backward' | 'forward';
|
||||
firstDayOfWeek: 'mo' | 'tu' | 'we' | 'th' | 'fr' | 'sa' | 'su';
|
||||
byDay: CalendarNDay[] | null;
|
||||
byMonthDay: number[] | null;
|
||||
byMonth: string[] | null;
|
||||
byYearDay: number[] | null;
|
||||
byWeekNo: number[] | null;
|
||||
byHour: number[] | null;
|
||||
byMinute: number[] | null;
|
||||
bySecond: number[] | null;
|
||||
bySetPosition: number[] | null;
|
||||
count: number | null;
|
||||
until: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarNDay {
|
||||
day: string;
|
||||
nthOfPeriod?: number;
|
||||
}
|
||||
|
||||
export interface CalendarEventAlert {
|
||||
'@type': 'Alert';
|
||||
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger;
|
||||
action: 'display' | 'email';
|
||||
acknowledged: string | null;
|
||||
relatedTo: Record<string, CalendarRelation> | null;
|
||||
}
|
||||
|
||||
export interface CalendarOffsetTrigger {
|
||||
'@type': 'OffsetTrigger';
|
||||
offset: string;
|
||||
relativeTo: 'start' | 'end';
|
||||
}
|
||||
|
||||
export interface CalendarAbsoluteTrigger {
|
||||
'@type': 'AbsoluteTrigger';
|
||||
when: string;
|
||||
}
|
||||
|
||||
export interface CalendarLocation {
|
||||
'@type': 'Location';
|
||||
name: string;
|
||||
description: string | null;
|
||||
locationTypes: Record<string, boolean> | null;
|
||||
coordinates: string | null;
|
||||
timeZone: string | null;
|
||||
links: Record<string, CalendarLink> | null;
|
||||
relativeTo: 'start' | 'end' | null;
|
||||
}
|
||||
|
||||
export interface CalendarVirtualLocation {
|
||||
'@type': 'VirtualLocation';
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
uri: string;
|
||||
features: Record<string, boolean> | null;
|
||||
}
|
||||
|
||||
export interface CalendarLink {
|
||||
'@type': 'Link';
|
||||
href: string;
|
||||
cid: string | null;
|
||||
contentType: string | null;
|
||||
size: number | null;
|
||||
rel: string | null;
|
||||
display: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarRelation {
|
||||
'@type': 'Relation';
|
||||
relation: Record<string, boolean> | null;
|
||||
}
|
||||
|
||||
export interface CalendarParticipantIdentity {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduleId: string;
|
||||
sendTo: Record<string, string>;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEventNotification {
|
||||
id: string;
|
||||
created: string;
|
||||
changedBy: {
|
||||
name: string;
|
||||
email: string;
|
||||
principalId: string | null;
|
||||
scheduleId: string | null;
|
||||
};
|
||||
comment: string | null;
|
||||
type: 'created' | 'updated' | 'destroyed';
|
||||
calendarEventId: string;
|
||||
isDraft: boolean;
|
||||
event?: CalendarEvent;
|
||||
eventPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CalendarEventFilter {
|
||||
inCalendars?: string[];
|
||||
after?: string;
|
||||
before?: string;
|
||||
text?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
owner?: string;
|
||||
attendee?: string;
|
||||
participationStatus?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
// JMAP Push Notification Types (RFC 8620 Section 7)
|
||||
|
||||
export interface StateChange {
|
||||
@@ -280,6 +498,8 @@ export interface StateChange {
|
||||
Identity?: string;
|
||||
ContactCard?: string;
|
||||
AddressBook?: string;
|
||||
Calendar?: string;
|
||||
CalendarEvent?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Speicher",
|
||||
"sign_out": "Abmelden",
|
||||
"contacts": "Kontakte",
|
||||
"calendar": "Kalender",
|
||||
"settings": "Einstellungen",
|
||||
"loading_mailboxes": "Postfächer werden geladen...",
|
||||
"push_connected": "Echtzeit-Updates aktiv",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Konto",
|
||||
"identities": "Identitäten",
|
||||
"vacation": "Abwesenheitsnotiz",
|
||||
"advanced": "Erweitert"
|
||||
"advanced": "Erweitert",
|
||||
"calendar": "Kalender"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Darstellung",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
"back_to_email": "Zurück zu E-Mails",
|
||||
"my_calendars": "Meine Kalender",
|
||||
"views": {
|
||||
"month": "Monat",
|
||||
"week": "Woche",
|
||||
"day": "Tag",
|
||||
"agenda": "Agenda",
|
||||
"today": "Heute"
|
||||
},
|
||||
"events": {
|
||||
"create": "Termin erstellen",
|
||||
"edit": "Termin bearbeiten",
|
||||
"delete": "Termin löschen",
|
||||
"details": "Termindetails",
|
||||
"no_events": "Keine Termine",
|
||||
"all_day": "Ganztägig",
|
||||
"more": "+{count} weitere",
|
||||
"no_title": "(Kein Titel)",
|
||||
"today_header": "Heute",
|
||||
"tomorrow_header": "Morgen"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"location": "Ort",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Enddatum",
|
||||
"start_time": "Startzeit",
|
||||
"end_time": "Endzeit",
|
||||
"all_day_event": "Ganztägiger Termin",
|
||||
"calendar_select": "Kalender",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Termin löschen möchten?",
|
||||
"color": "Farbe"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Teilnehmer",
|
||||
"add": "Teilnehmer hinzufügen",
|
||||
"organizer": "Organisator",
|
||||
"attendee": "Teilnehmer",
|
||||
"accepted": "Zugesagt",
|
||||
"declined": "Abgelehnt",
|
||||
"tentative": "Vorläufig",
|
||||
"needs_action": "Antwort ausstehend"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Wiederholung",
|
||||
"none": "Keine Wiederholung",
|
||||
"daily": "Täglich",
|
||||
"weekly": "Wöchentlich",
|
||||
"monthly": "Monatlich",
|
||||
"yearly": "Jährlich",
|
||||
"every_n_days": "Alle {count} Tage",
|
||||
"every_n_weeks": "Alle {count} Wochen",
|
||||
"every_n_months": "Alle {count} Monate",
|
||||
"until": "Bis",
|
||||
"occurrences": "{count} Wiederholungen"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Erinnerung",
|
||||
"none": "Keine Erinnerung",
|
||||
"at_time": "Zum Zeitpunkt des Termins",
|
||||
"minutes_before": "{count, plural, one {# Minute vorher} other {# Minuten vorher}}",
|
||||
"hours_before": "{count, plural, one {# Stunde vorher} other {# Stunden vorher}}",
|
||||
"days_before": "{count, plural, one {# Tag vorher} other {# Tage vorher}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Kalendereinstellungen",
|
||||
"default_view": "Standardansicht",
|
||||
"week_starts_on": "Woche beginnt am",
|
||||
"time_format": "Zeitformat",
|
||||
"default_calendar": "Standardkalender",
|
||||
"default_reminder": "Standarderinnerung",
|
||||
"time_format_12h": "12-Stunden",
|
||||
"time_format_24h": "24-Stunden"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Montag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch",
|
||||
"thursday": "Donnerstag",
|
||||
"friday": "Freitag",
|
||||
"saturday": "Samstag",
|
||||
"sunday": "Sonntag",
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Termin erstellt",
|
||||
"event_updated": "Termin aktualisiert",
|
||||
"event_deleted": "Termin gelöscht",
|
||||
"calendar_created": "Kalender erstellt",
|
||||
"calendar_deleted": "Kalender gelöscht"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Kalender werden geladen...",
|
||||
"loading_events": "Termine werden geladen..."
|
||||
},
|
||||
"nav_prev": "Zurück",
|
||||
"nav_next": "Weiter"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Erweiterte Suche",
|
||||
"from": "Von",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Storage",
|
||||
"sign_out": "Sign out",
|
||||
"contacts": "Contacts",
|
||||
"calendar": "Calendar",
|
||||
"settings": "Settings",
|
||||
"loading_mailboxes": "Loading mailboxes...",
|
||||
"push_connected": "Real-time updates active",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Account",
|
||||
"identities": "Identities",
|
||||
"vacation": "Vacation Responder",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"calendar": "Calendar"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Failed to delete contact"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"back_to_email": "Back to email",
|
||||
"my_calendars": "My calendars",
|
||||
"views": {
|
||||
"month": "Month",
|
||||
"week": "Week",
|
||||
"day": "Day",
|
||||
"agenda": "Agenda",
|
||||
"today": "Today"
|
||||
},
|
||||
"events": {
|
||||
"create": "Create event",
|
||||
"edit": "Edit event",
|
||||
"delete": "Delete event",
|
||||
"details": "Event details",
|
||||
"no_events": "No events",
|
||||
"all_day": "All day",
|
||||
"more": "+{count} more",
|
||||
"no_title": "(No title)",
|
||||
"today_header": "Today",
|
||||
"tomorrow_header": "Tomorrow"
|
||||
},
|
||||
"form": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"start_date": "Start date",
|
||||
"end_date": "End date",
|
||||
"start_time": "Start time",
|
||||
"end_time": "End time",
|
||||
"all_day_event": "All-day event",
|
||||
"calendar_select": "Calendar",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Are you sure you want to delete this event?",
|
||||
"color": "Color"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Participants",
|
||||
"add": "Add participant",
|
||||
"organizer": "Organizer",
|
||||
"attendee": "Attendee",
|
||||
"accepted": "Accepted",
|
||||
"declined": "Declined",
|
||||
"tentative": "Tentative",
|
||||
"needs_action": "Needs action"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Recurrence",
|
||||
"none": "Does not repeat",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"every_n_days": "Every {count} days",
|
||||
"every_n_weeks": "Every {count} weeks",
|
||||
"every_n_months": "Every {count} months",
|
||||
"until": "Until",
|
||||
"occurrences": "{count} occurrences"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Reminder",
|
||||
"none": "No reminder",
|
||||
"at_time": "At time of event",
|
||||
"minutes_before": "{count, plural, one {# minute before} other {# minutes before}}",
|
||||
"hours_before": "{count, plural, one {# hour before} other {# hours before}}",
|
||||
"days_before": "{count, plural, one {# day before} other {# days before}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Calendar settings",
|
||||
"default_view": "Default view",
|
||||
"week_starts_on": "Week starts on",
|
||||
"time_format": "Time format",
|
||||
"default_calendar": "Default calendar",
|
||||
"default_reminder": "Default reminder",
|
||||
"time_format_12h": "12-hour",
|
||||
"time_format_24h": "24-hour"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"sunday": "Sunday",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Event created",
|
||||
"event_updated": "Event updated",
|
||||
"event_deleted": "Event deleted",
|
||||
"calendar_created": "Calendar created",
|
||||
"calendar_deleted": "Calendar deleted"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Loading calendars...",
|
||||
"loading_events": "Loading events..."
|
||||
},
|
||||
"nav_prev": "Previous",
|
||||
"nav_next": "Next"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Advanced Search",
|
||||
"from": "From",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Almacenamiento",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"contacts": "Contactos",
|
||||
"calendar": "Calendario",
|
||||
"settings": "Configuración",
|
||||
"loading_mailboxes": "Cargando buzones...",
|
||||
"push_connected": "Actualizaciones en tiempo real activas",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Cuenta",
|
||||
"identities": "Identidades",
|
||||
"vacation": "Respuesta automática",
|
||||
"advanced": "Avanzado"
|
||||
"advanced": "Avanzado",
|
||||
"calendar": "Calendario"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apariencia",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Error al eliminar el contacto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
"back_to_email": "Volver al correo",
|
||||
"my_calendars": "Mis calendarios",
|
||||
"views": {
|
||||
"month": "Mes",
|
||||
"week": "Semana",
|
||||
"day": "Día",
|
||||
"agenda": "Agenda",
|
||||
"today": "Hoy"
|
||||
},
|
||||
"events": {
|
||||
"create": "Crear evento",
|
||||
"edit": "Editar evento",
|
||||
"delete": "Eliminar evento",
|
||||
"details": "Detalles del evento",
|
||||
"no_events": "Sin eventos",
|
||||
"all_day": "Todo el día",
|
||||
"more": "+{count} más",
|
||||
"no_title": "(Sin título)",
|
||||
"today_header": "Hoy",
|
||||
"tomorrow_header": "Mañana"
|
||||
},
|
||||
"form": {
|
||||
"title": "Título",
|
||||
"description": "Descripción",
|
||||
"location": "Ubicación",
|
||||
"start_date": "Fecha de inicio",
|
||||
"end_date": "Fecha de fin",
|
||||
"start_time": "Hora de inicio",
|
||||
"end_time": "Hora de fin",
|
||||
"all_day_event": "Evento de todo el día",
|
||||
"calendar_select": "Calendario",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete_confirm": "¿Está seguro de que desea eliminar este evento?",
|
||||
"color": "Color"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Participantes",
|
||||
"add": "Agregar participante",
|
||||
"organizer": "Organizador",
|
||||
"attendee": "Participante",
|
||||
"accepted": "Aceptado",
|
||||
"declined": "Rechazado",
|
||||
"tentative": "Provisional",
|
||||
"needs_action": "Pendiente de respuesta"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Recurrencia",
|
||||
"none": "No se repite",
|
||||
"daily": "Diario",
|
||||
"weekly": "Semanal",
|
||||
"monthly": "Mensual",
|
||||
"yearly": "Anual",
|
||||
"every_n_days": "Cada {count} días",
|
||||
"every_n_weeks": "Cada {count} semanas",
|
||||
"every_n_months": "Cada {count} meses",
|
||||
"until": "Hasta",
|
||||
"occurrences": "{count} repeticiones"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Recordatorio",
|
||||
"none": "Sin recordatorio",
|
||||
"at_time": "En el momento del evento",
|
||||
"minutes_before": "{count, plural, one {# minuto antes} other {# minutos antes}}",
|
||||
"hours_before": "{count, plural, one {# hora antes} other {# horas antes}}",
|
||||
"days_before": "{count, plural, one {# día antes} other {# días antes}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración del calendario",
|
||||
"default_view": "Vista predeterminada",
|
||||
"week_starts_on": "La semana comienza el",
|
||||
"time_format": "Formato de hora",
|
||||
"default_calendar": "Calendario predeterminado",
|
||||
"default_reminder": "Recordatorio predeterminado",
|
||||
"time_format_12h": "12 horas",
|
||||
"time_format_24h": "24 horas"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Evento creado",
|
||||
"event_updated": "Evento actualizado",
|
||||
"event_deleted": "Evento eliminado",
|
||||
"calendar_created": "Calendario creado",
|
||||
"calendar_deleted": "Calendario eliminado"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Cargando calendarios...",
|
||||
"loading_events": "Cargando eventos..."
|
||||
},
|
||||
"nav_prev": "Anterior",
|
||||
"nav_next": "Siguiente"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Búsqueda avanzada",
|
||||
"from": "De",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Stockage",
|
||||
"sign_out": "Se déconnecter",
|
||||
"contacts": "Contacts",
|
||||
"calendar": "Calendrier",
|
||||
"settings": "Paramètres",
|
||||
"loading_mailboxes": "Chargement des boîtes mail...",
|
||||
"push_connected": "Mises à jour en temps réel actives",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Compte",
|
||||
"identities": "Identités",
|
||||
"vacation": "Répondeur d'absence",
|
||||
"advanced": "Avancé"
|
||||
"advanced": "Avancé",
|
||||
"calendar": "Calendrier"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Échec de la suppression du contact"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendrier",
|
||||
"back_to_email": "Retour aux e-mails",
|
||||
"my_calendars": "Mes calendriers",
|
||||
"views": {
|
||||
"month": "Mois",
|
||||
"week": "Semaine",
|
||||
"day": "Jour",
|
||||
"agenda": "Agenda",
|
||||
"today": "Aujourd'hui"
|
||||
},
|
||||
"events": {
|
||||
"create": "Créer un événement",
|
||||
"edit": "Modifier l'événement",
|
||||
"delete": "Supprimer l'événement",
|
||||
"details": "Détails de l'événement",
|
||||
"no_events": "Aucun événement",
|
||||
"all_day": "Toute la journée",
|
||||
"more": "+{count} de plus",
|
||||
"no_title": "(Sans titre)",
|
||||
"today_header": "Aujourd'hui",
|
||||
"tomorrow_header": "Demain"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
"location": "Lieu",
|
||||
"start_date": "Date de début",
|
||||
"end_date": "Date de fin",
|
||||
"start_time": "Heure de début",
|
||||
"end_time": "Heure de fin",
|
||||
"all_day_event": "Événement sur toute la journée",
|
||||
"calendar_select": "Calendrier",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer cet événement ?",
|
||||
"color": "Couleur"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Participants",
|
||||
"add": "Ajouter un participant",
|
||||
"organizer": "Organisateur",
|
||||
"attendee": "Participant",
|
||||
"accepted": "Accepté",
|
||||
"declined": "Refusé",
|
||||
"tentative": "Provisoire",
|
||||
"needs_action": "En attente de réponse"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Récurrence",
|
||||
"none": "Ne se répète pas",
|
||||
"daily": "Quotidien",
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel",
|
||||
"every_n_days": "Tous les {count} jours",
|
||||
"every_n_weeks": "Toutes les {count} semaines",
|
||||
"every_n_months": "Tous les {count} mois",
|
||||
"until": "Jusqu'au",
|
||||
"occurrences": "{count} occurrences"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Rappel",
|
||||
"none": "Aucun rappel",
|
||||
"at_time": "Au moment de l'événement",
|
||||
"minutes_before": "{count, plural, one {# minute avant} other {# minutes avant}}",
|
||||
"hours_before": "{count, plural, one {# heure avant} other {# heures avant}}",
|
||||
"days_before": "{count, plural, one {# jour avant} other {# jours avant}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres du calendrier",
|
||||
"default_view": "Vue par défaut",
|
||||
"week_starts_on": "La semaine commence le",
|
||||
"time_format": "Format de l'heure",
|
||||
"default_calendar": "Calendrier par défaut",
|
||||
"default_reminder": "Rappel par défaut",
|
||||
"time_format_12h": "12 heures",
|
||||
"time_format_24h": "24 heures"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lundi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi",
|
||||
"thursday": "Jeudi",
|
||||
"friday": "Vendredi",
|
||||
"saturday": "Samedi",
|
||||
"sunday": "Dimanche",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Jeu",
|
||||
"fri": "Ven",
|
||||
"sat": "Sam",
|
||||
"sun": "Dim"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Événement créé",
|
||||
"event_updated": "Événement mis à jour",
|
||||
"event_deleted": "Événement supprimé",
|
||||
"calendar_created": "Calendrier créé",
|
||||
"calendar_deleted": "Calendrier supprimé"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Chargement des calendriers...",
|
||||
"loading_events": "Chargement des événements..."
|
||||
},
|
||||
"nav_prev": "Précédent",
|
||||
"nav_next": "Suivant"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Recherche avancée",
|
||||
"from": "De",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Spazio di archiviazione",
|
||||
"sign_out": "Esci",
|
||||
"contacts": "Contatti",
|
||||
"calendar": "Calendario",
|
||||
"settings": "Impostazioni",
|
||||
"loading_mailboxes": "Caricamento caselle di posta...",
|
||||
"push_connected": "Aggiornamenti in tempo reale attivi",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Account",
|
||||
"identities": "Identità",
|
||||
"vacation": "Risponditore automatico",
|
||||
"advanced": "Avanzate"
|
||||
"advanced": "Avanzate",
|
||||
"calendar": "Calendario"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aspetto",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Impossibile eliminare il contatto"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
"back_to_email": "Torna alla posta",
|
||||
"my_calendars": "I miei calendari",
|
||||
"views": {
|
||||
"month": "Mese",
|
||||
"week": "Settimana",
|
||||
"day": "Giorno",
|
||||
"agenda": "Agenda",
|
||||
"today": "Oggi"
|
||||
},
|
||||
"events": {
|
||||
"create": "Crea evento",
|
||||
"edit": "Modifica evento",
|
||||
"delete": "Elimina evento",
|
||||
"details": "Dettagli evento",
|
||||
"no_events": "Nessun evento",
|
||||
"all_day": "Tutto il giorno",
|
||||
"more": "+{count} altri",
|
||||
"no_title": "(Senza titolo)",
|
||||
"today_header": "Oggi",
|
||||
"tomorrow_header": "Domani"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titolo",
|
||||
"description": "Descrizione",
|
||||
"location": "Luogo",
|
||||
"start_date": "Data di inizio",
|
||||
"end_date": "Data di fine",
|
||||
"start_time": "Ora di inizio",
|
||||
"end_time": "Ora di fine",
|
||||
"all_day_event": "Evento giornata intera",
|
||||
"calendar_select": "Calendario",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo evento?",
|
||||
"color": "Colore"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Partecipanti",
|
||||
"add": "Aggiungi partecipante",
|
||||
"organizer": "Organizzatore",
|
||||
"attendee": "Partecipante",
|
||||
"accepted": "Accettato",
|
||||
"declined": "Rifiutato",
|
||||
"tentative": "Provvisorio",
|
||||
"needs_action": "In attesa di risposta"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Ricorrenza",
|
||||
"none": "Non si ripete",
|
||||
"daily": "Giornaliero",
|
||||
"weekly": "Settimanale",
|
||||
"monthly": "Mensile",
|
||||
"yearly": "Annuale",
|
||||
"every_n_days": "Ogni {count} giorni",
|
||||
"every_n_weeks": "Ogni {count} settimane",
|
||||
"every_n_months": "Ogni {count} mesi",
|
||||
"until": "Fino al",
|
||||
"occurrences": "{count} ripetizioni"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Promemoria",
|
||||
"none": "Nessun promemoria",
|
||||
"at_time": "All'ora dell'evento",
|
||||
"minutes_before": "{count, plural, one {# minuto prima} other {# minuti prima}}",
|
||||
"hours_before": "{count, plural, one {# ora prima} other {# ore prima}}",
|
||||
"days_before": "{count, plural, one {# giorno prima} other {# giorni prima}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni calendario",
|
||||
"default_view": "Vista predefinita",
|
||||
"week_starts_on": "La settimana inizia il",
|
||||
"time_format": "Formato ora",
|
||||
"default_calendar": "Calendario predefinito",
|
||||
"default_reminder": "Promemoria predefinito",
|
||||
"time_format_12h": "12 ore",
|
||||
"time_format_24h": "24 ore"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì",
|
||||
"thursday": "Giovedì",
|
||||
"friday": "Venerdì",
|
||||
"saturday": "Sabato",
|
||||
"sunday": "Domenica",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Gio",
|
||||
"fri": "Ven",
|
||||
"sat": "Sab",
|
||||
"sun": "Dom"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Evento creato",
|
||||
"event_updated": "Evento aggiornato",
|
||||
"event_deleted": "Evento eliminato",
|
||||
"calendar_created": "Calendario creato",
|
||||
"calendar_deleted": "Calendario eliminato"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Caricamento calendari...",
|
||||
"loading_events": "Caricamento eventi..."
|
||||
},
|
||||
"nav_prev": "Precedente",
|
||||
"nav_next": "Successivo"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Ricerca avanzata",
|
||||
"from": "Da",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "ストレージ",
|
||||
"sign_out": "サインアウト",
|
||||
"contacts": "連絡先",
|
||||
"calendar": "カレンダー",
|
||||
"settings": "設定",
|
||||
"loading_mailboxes": "メールボックスを読み込み中...",
|
||||
"push_connected": "リアルタイム更新が有効",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "アカウント",
|
||||
"identities": "送信者情報",
|
||||
"vacation": "不在応答",
|
||||
"advanced": "詳細設定"
|
||||
"advanced": "詳細設定",
|
||||
"calendar": "カレンダー"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外観",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "連絡先の削除に失敗しました"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "カレンダー",
|
||||
"back_to_email": "メールに戻る",
|
||||
"my_calendars": "マイカレンダー",
|
||||
"views": {
|
||||
"month": "月",
|
||||
"week": "週",
|
||||
"day": "日",
|
||||
"agenda": "予定リスト",
|
||||
"today": "今日"
|
||||
},
|
||||
"events": {
|
||||
"create": "予定を作成",
|
||||
"edit": "予定を編集",
|
||||
"delete": "予定を削除",
|
||||
"details": "予定の詳細",
|
||||
"no_events": "予定なし",
|
||||
"all_day": "終日",
|
||||
"more": "他{count}件",
|
||||
"no_title": "(タイトルなし)",
|
||||
"today_header": "今日",
|
||||
"tomorrow_header": "明日"
|
||||
},
|
||||
"form": {
|
||||
"title": "タイトル",
|
||||
"description": "説明",
|
||||
"location": "場所",
|
||||
"start_date": "開始日",
|
||||
"end_date": "終了日",
|
||||
"start_time": "開始時刻",
|
||||
"end_time": "終了時刻",
|
||||
"all_day_event": "終日の予定",
|
||||
"calendar_select": "カレンダー",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete_confirm": "この予定を削除してもよろしいですか?",
|
||||
"color": "色"
|
||||
},
|
||||
"participants": {
|
||||
"title": "参加者",
|
||||
"add": "参加者を追加",
|
||||
"organizer": "主催者",
|
||||
"attendee": "参加者",
|
||||
"accepted": "承諾",
|
||||
"declined": "辞退",
|
||||
"tentative": "仮承諾",
|
||||
"needs_action": "未回答"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "繰り返し",
|
||||
"none": "繰り返しなし",
|
||||
"daily": "毎日",
|
||||
"weekly": "毎週",
|
||||
"monthly": "毎月",
|
||||
"yearly": "毎年",
|
||||
"every_n_days": "{count}日ごと",
|
||||
"every_n_weeks": "{count}週間ごと",
|
||||
"every_n_months": "{count}か月ごと",
|
||||
"until": "終了日",
|
||||
"occurrences": "{count}回"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "リマインダー",
|
||||
"none": "リマインダーなし",
|
||||
"at_time": "予定の時刻",
|
||||
"minutes_before": "{count}分前",
|
||||
"hours_before": "{count}時間前",
|
||||
"days_before": "{count}日前"
|
||||
},
|
||||
"settings": {
|
||||
"title": "カレンダー設定",
|
||||
"default_view": "デフォルトの表示",
|
||||
"week_starts_on": "週の開始曜日",
|
||||
"time_format": "時刻の形式",
|
||||
"default_calendar": "デフォルトのカレンダー",
|
||||
"default_reminder": "デフォルトのリマインダー",
|
||||
"time_format_12h": "12時間制",
|
||||
"time_format_24h": "24時間制"
|
||||
},
|
||||
"days": {
|
||||
"monday": "月曜日",
|
||||
"tuesday": "火曜日",
|
||||
"wednesday": "水曜日",
|
||||
"thursday": "木曜日",
|
||||
"friday": "金曜日",
|
||||
"saturday": "土曜日",
|
||||
"sunday": "日曜日",
|
||||
"mon": "月",
|
||||
"tue": "火",
|
||||
"wed": "水",
|
||||
"thu": "木",
|
||||
"fri": "金",
|
||||
"sat": "土",
|
||||
"sun": "日"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "予定を作成しました",
|
||||
"event_updated": "予定を更新しました",
|
||||
"event_deleted": "予定を削除しました",
|
||||
"calendar_created": "カレンダーを作成しました",
|
||||
"calendar_deleted": "カレンダーを削除しました"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "カレンダーを読み込み中...",
|
||||
"loading_events": "予定を読み込み中..."
|
||||
},
|
||||
"nav_prev": "前へ",
|
||||
"nav_next": "次へ"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "詳細検索",
|
||||
"from": "差出人",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Opslag",
|
||||
"sign_out": "Afmelden",
|
||||
"contacts": "Contacten",
|
||||
"calendar": "Agenda",
|
||||
"settings": "Instellingen",
|
||||
"loading_mailboxes": "Mappen laden...",
|
||||
"push_connected": "Real-time updates actief",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Account",
|
||||
"identities": "Identiteiten",
|
||||
"vacation": "Afwezigheidsmelder",
|
||||
"advanced": "Geavanceerd"
|
||||
"advanced": "Geavanceerd",
|
||||
"calendar": "Agenda"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Uiterlijk",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Kon contact niet verwijderen"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Agenda",
|
||||
"back_to_email": "Terug naar e-mail",
|
||||
"my_calendars": "Mijn agenda's",
|
||||
"views": {
|
||||
"month": "Maand",
|
||||
"week": "Week",
|
||||
"day": "Dag",
|
||||
"agenda": "Agenda",
|
||||
"today": "Vandaag"
|
||||
},
|
||||
"events": {
|
||||
"create": "Evenement aanmaken",
|
||||
"edit": "Evenement bewerken",
|
||||
"delete": "Evenement verwijderen",
|
||||
"details": "Evenementdetails",
|
||||
"no_events": "Geen evenementen",
|
||||
"all_day": "Hele dag",
|
||||
"more": "+{count} meer",
|
||||
"no_title": "(Geen titel)",
|
||||
"today_header": "Vandaag",
|
||||
"tomorrow_header": "Morgen"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titel",
|
||||
"description": "Beschrijving",
|
||||
"location": "Locatie",
|
||||
"start_date": "Startdatum",
|
||||
"end_date": "Einddatum",
|
||||
"start_time": "Starttijd",
|
||||
"end_time": "Eindtijd",
|
||||
"all_day_event": "Hele dag evenement",
|
||||
"calendar_select": "Agenda",
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"delete_confirm": "Weet je zeker dat je dit evenement wilt verwijderen?",
|
||||
"color": "Kleur"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Deelnemers",
|
||||
"add": "Deelnemer toevoegen",
|
||||
"organizer": "Organisator",
|
||||
"attendee": "Deelnemer",
|
||||
"accepted": "Geaccepteerd",
|
||||
"declined": "Geweigerd",
|
||||
"tentative": "Voorlopig",
|
||||
"needs_action": "Reactie vereist"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Herhaling",
|
||||
"none": "Wordt niet herhaald",
|
||||
"daily": "Dagelijks",
|
||||
"weekly": "Wekelijks",
|
||||
"monthly": "Maandelijks",
|
||||
"yearly": "Jaarlijks",
|
||||
"every_n_days": "Elke {count} dagen",
|
||||
"every_n_weeks": "Elke {count} weken",
|
||||
"every_n_months": "Elke {count} maanden",
|
||||
"until": "Tot",
|
||||
"occurrences": "{count} herhalingen"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Herinnering",
|
||||
"none": "Geen herinnering",
|
||||
"at_time": "Op het tijdstip van het evenement",
|
||||
"minutes_before": "{count, plural, one {# minuut van tevoren} other {# minuten van tevoren}}",
|
||||
"hours_before": "{count, plural, one {# uur van tevoren} other {# uur van tevoren}}",
|
||||
"days_before": "{count, plural, one {# dag van tevoren} other {# dagen van tevoren}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Agenda-instellingen",
|
||||
"default_view": "Standaardweergave",
|
||||
"week_starts_on": "Week begint op",
|
||||
"time_format": "Tijdnotatie",
|
||||
"default_calendar": "Standaardagenda",
|
||||
"default_reminder": "Standaardherinnering",
|
||||
"time_format_12h": "12-uurs",
|
||||
"time_format_24h": "24-uurs"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Maandag",
|
||||
"tuesday": "Dinsdag",
|
||||
"wednesday": "Woensdag",
|
||||
"thursday": "Donderdag",
|
||||
"friday": "Vrijdag",
|
||||
"saturday": "Zaterdag",
|
||||
"sunday": "Zondag",
|
||||
"mon": "Ma",
|
||||
"tue": "Di",
|
||||
"wed": "Wo",
|
||||
"thu": "Do",
|
||||
"fri": "Vr",
|
||||
"sat": "Za",
|
||||
"sun": "Zo"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Evenement aangemaakt",
|
||||
"event_updated": "Evenement bijgewerkt",
|
||||
"event_deleted": "Evenement verwijderd",
|
||||
"calendar_created": "Agenda aangemaakt",
|
||||
"calendar_deleted": "Agenda verwijderd"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Agenda's laden...",
|
||||
"loading_events": "Evenementen laden..."
|
||||
},
|
||||
"nav_prev": "Vorige",
|
||||
"nav_next": "Volgende"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Geavanceerd zoeken",
|
||||
"from": "Van",
|
||||
|
||||
+112
-1
@@ -32,6 +32,7 @@
|
||||
"storage": "Armazenamento",
|
||||
"sign_out": "Sair",
|
||||
"contacts": "Contatos",
|
||||
"calendar": "Calendário",
|
||||
"settings": "Configurações",
|
||||
"loading_mailboxes": "Carregando caixas de entrada...",
|
||||
"push_connected": "Atualizações em tempo real ativas",
|
||||
@@ -375,7 +376,8 @@
|
||||
"account": "Conta",
|
||||
"identities": "Identidades",
|
||||
"vacation": "Resposta automática",
|
||||
"advanced": "Avançado"
|
||||
"advanced": "Avançado",
|
||||
"calendar": "Calendário"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência",
|
||||
@@ -902,6 +904,115 @@
|
||||
"error_delete": "Falha ao excluir contato"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendário",
|
||||
"back_to_email": "Voltar ao e-mail",
|
||||
"my_calendars": "Meus calendários",
|
||||
"views": {
|
||||
"month": "Mês",
|
||||
"week": "Semana",
|
||||
"day": "Dia",
|
||||
"agenda": "Agenda",
|
||||
"today": "Hoje"
|
||||
},
|
||||
"events": {
|
||||
"create": "Criar evento",
|
||||
"edit": "Editar evento",
|
||||
"delete": "Excluir evento",
|
||||
"details": "Detalhes do evento",
|
||||
"no_events": "Nenhum evento",
|
||||
"all_day": "Dia inteiro",
|
||||
"more": "+{count} mais",
|
||||
"no_title": "(Sem título)",
|
||||
"today_header": "Hoje",
|
||||
"tomorrow_header": "Amanhã"
|
||||
},
|
||||
"form": {
|
||||
"title": "Título",
|
||||
"description": "Descrição",
|
||||
"location": "Local",
|
||||
"start_date": "Data de início",
|
||||
"end_date": "Data de término",
|
||||
"start_time": "Hora de início",
|
||||
"end_time": "Hora de término",
|
||||
"all_day_event": "Evento de dia inteiro",
|
||||
"calendar_select": "Calendário",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir este evento?",
|
||||
"color": "Cor"
|
||||
},
|
||||
"participants": {
|
||||
"title": "Participantes",
|
||||
"add": "Adicionar participante",
|
||||
"organizer": "Organizador",
|
||||
"attendee": "Participante",
|
||||
"accepted": "Aceito",
|
||||
"declined": "Recusado",
|
||||
"tentative": "Provisório",
|
||||
"needs_action": "Aguardando resposta"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Recorrência",
|
||||
"none": "Não se repete",
|
||||
"daily": "Diário",
|
||||
"weekly": "Semanal",
|
||||
"monthly": "Mensal",
|
||||
"yearly": "Anual",
|
||||
"every_n_days": "A cada {count} dias",
|
||||
"every_n_weeks": "A cada {count} semanas",
|
||||
"every_n_months": "A cada {count} meses",
|
||||
"until": "Até",
|
||||
"occurrences": "{count} repetições"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Lembrete",
|
||||
"none": "Sem lembrete",
|
||||
"at_time": "No momento do evento",
|
||||
"minutes_before": "{count, plural, one {# minuto antes} other {# minutos antes}}",
|
||||
"hours_before": "{count, plural, one {# hora antes} other {# horas antes}}",
|
||||
"days_before": "{count, plural, one {# dia antes} other {# dias antes}}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações do calendário",
|
||||
"default_view": "Visualização padrão",
|
||||
"week_starts_on": "Semana começa em",
|
||||
"time_format": "Formato de hora",
|
||||
"default_calendar": "Calendário padrão",
|
||||
"default_reminder": "Lembrete padrão",
|
||||
"time_format_12h": "12 horas",
|
||||
"time_format_24h": "24 horas"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Segunda-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira",
|
||||
"thursday": "Quinta-feira",
|
||||
"friday": "Sexta-feira",
|
||||
"saturday": "Sábado",
|
||||
"sunday": "Domingo",
|
||||
"mon": "Seg",
|
||||
"tue": "Ter",
|
||||
"wed": "Qua",
|
||||
"thu": "Qui",
|
||||
"fri": "Sex",
|
||||
"sat": "Sáb",
|
||||
"sun": "Dom"
|
||||
},
|
||||
"notifications": {
|
||||
"event_created": "Evento criado",
|
||||
"event_updated": "Evento atualizado",
|
||||
"event_deleted": "Evento excluído",
|
||||
"calendar_created": "Calendário criado",
|
||||
"calendar_deleted": "Calendário excluído"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Carregando calendários...",
|
||||
"loading_events": "Carregando eventos..."
|
||||
},
|
||||
"nav_prev": "Anterior",
|
||||
"nav_next": "Próximo"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pesquisa avançada",
|
||||
"from": "De",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import { useVacationStore } from './vacation-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -72,6 +73,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
vacationStore.setSupported(false);
|
||||
}
|
||||
|
||||
// Initialize calendar if supported
|
||||
if (client.supportsCalendars()) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
calendarStore.setSupported(true);
|
||||
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
@@ -152,6 +160,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Clear vacation store state
|
||||
useVacationStore.getState().clearState();
|
||||
|
||||
// Clear calendar store state
|
||||
useCalendarStore.getState().clearState();
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
|
||||
interface CalendarStore {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
selectedDate: Date;
|
||||
viewMode: CalendarViewMode;
|
||||
selectedCalendarIds: string[];
|
||||
selectedEventId: string | null;
|
||||
isLoading: boolean;
|
||||
isLoadingEvents: boolean;
|
||||
supportsCalendar: boolean;
|
||||
error: string | null;
|
||||
dateRange: { start: string; end: string } | null;
|
||||
|
||||
setSupported: (supported: boolean) => void;
|
||||
fetchCalendars: (client: JMAPClient) => Promise<void>;
|
||||
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
|
||||
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
|
||||
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
||||
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
|
||||
setSelectedDate: (date: Date) => void;
|
||||
setViewMode: (mode: CalendarViewMode) => void;
|
||||
toggleCalendarVisibility: (calendarId: string) => void;
|
||||
setSelectedEventId: (id: string | null) => void;
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
calendars: [],
|
||||
events: [],
|
||||
selectedDate: new Date(),
|
||||
selectedCalendarIds: [] as string[],
|
||||
selectedEventId: null as string | null,
|
||||
isLoading: false,
|
||||
isLoadingEvents: false,
|
||||
supportsCalendar: false,
|
||||
error: null as string | null,
|
||||
dateRange: null as { start: string; end: string } | null,
|
||||
};
|
||||
|
||||
export const useCalendarStore = create<CalendarStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
viewMode: 'month' as CalendarViewMode,
|
||||
|
||||
setSupported: (supported) => set({ supportsCalendar: supported }),
|
||||
|
||||
fetchCalendars: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const calendars = await client.getCalendars();
|
||||
const { selectedCalendarIds } = get();
|
||||
set({
|
||||
calendars,
|
||||
isLoading: false,
|
||||
selectedCalendarIds: selectedCalendarIds.length === 0
|
||||
? calendars.map(c => c.id)
|
||||
: selectedCalendarIds,
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch calendars:', error);
|
||||
set({ error: 'Failed to load calendars', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchEvents: async (client, start, end) => {
|
||||
set({ isLoadingEvents: true, error: null });
|
||||
try {
|
||||
const { selectedCalendarIds } = get();
|
||||
const events = await client.queryCalendarEvents({
|
||||
after: start,
|
||||
before: end,
|
||||
inCalendars: selectedCalendarIds.length > 0 ? selectedCalendarIds : undefined,
|
||||
});
|
||||
set({ events, isLoadingEvents: false, dateRange: { start, end } });
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch events:', error);
|
||||
set({ error: 'Failed to load events', isLoadingEvents: false });
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: async (client, event) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const created = await client.createCalendarEvent(event);
|
||||
set((state) => ({ events: [...state.events, created] }));
|
||||
return created;
|
||||
} catch (error) {
|
||||
debug.error('Failed to create event:', error);
|
||||
set({ error: 'Failed to create event' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateEvent: async (client, id, updates) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.updateCalendarEvent(id, updates);
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to update event:', error);
|
||||
set({ error: 'Failed to update event' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteEvent: async (client, id) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.deleteCalendarEvent(id);
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => e.id !== id),
|
||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to delete event:', error);
|
||||
set({ error: 'Failed to delete event' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedDate: (date) => set({ selectedDate: date }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
|
||||
toggleCalendarVisibility: (calendarId) => set((state) => {
|
||||
const ids = state.selectedCalendarIds;
|
||||
return {
|
||||
selectedCalendarIds: ids.includes(calendarId)
|
||||
? ids.filter(id => id !== calendarId)
|
||||
: [...ids, calendarId],
|
||||
};
|
||||
}),
|
||||
|
||||
setSelectedEventId: (id) => set({ selectedEventId: id }),
|
||||
|
||||
clearState: () => set({
|
||||
...initialState,
|
||||
selectedDate: new Date(),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'calendar-storage',
|
||||
partialize: (state) => ({
|
||||
selectedCalendarIds: state.selectedCalendarIds,
|
||||
viewMode: state.viewMode,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
+12
-1
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||
import { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface EmailStore {
|
||||
@@ -1010,7 +1011,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
await get().fetchMailboxes(client);
|
||||
}
|
||||
|
||||
// Could also handle Thread, EmailSubmission, Identity changes in the future
|
||||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
||||
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
if (calendarStore.supportsCalendar) {
|
||||
calendarStore.fetchCalendars(client);
|
||||
const { dateRange, selectedCalendarIds } = calendarStore;
|
||||
if (dateRange && selectedCalendarIds.length > 0) {
|
||||
calendarStore.fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle state change:', error);
|
||||
set({
|
||||
|
||||
Reference in New Issue
Block a user