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:
Matthieu MALVACHE
2026-02-16 20:34:29 +01:00
committed by Matthieu MALVACHE
parent fb414bf2c9
commit e6f67c24cb
28 changed files with 3922 additions and 39 deletions
+11
View File
@@ -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
View File
@@ -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)
+335
View File
@@ -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>
);
}
+5 -1
View File
@@ -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>
);
}
+235
View File
@@ -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>
);
}
+170
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+313
View File
@@ -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>
);
}
+94
View File
@@ -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 };
+444
View File
@@ -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>
);
}
+217
View File
@@ -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>
);
}
+17
View File
@@ -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')}
+55
View File
@@ -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
View File
@@ -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;
}
}
+220
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+11
View File
@@ -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 () => {
+159
View File
@@ -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
View File
@@ -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({