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
@@ -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>
);
}