ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
@@ -0,0 +1,129 @@
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
import { useLocation } from "wouter";
import type { CalendarEvent } from "@/jmap/types";
import { useCalendar, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
import { CALENDAR_COLORS } from "@/ui/misc";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
export type CalendarContext =
| { kind: "event"; inst: EventInstance; anchor: Anchor }
| { kind: "slot"; start: Date; end: Date; allDay: boolean; anchor: Anchor };
interface Props {
ctx: CalendarContext;
onClose: () => void;
onOpen: (inst: EventInstance, anchor: Anchor) => void;
onEdit: (inst: EventInstance) => void;
onCreate: (start: Date, end: Date, allDay: boolean) => void;
}
/** Resolve the display colour of an event: explicit colour → category colour → calendar colour. */
export function eventColor(ev: CalendarEvent, calendarColor: string | null | undefined, categories: Array<{ name: string; color: string }>): string {
if (ev.color) return ev.color;
const cat = categoryOf(ev, categories);
if (cat) return cat.color;
return calendarColor ?? "var(--accent)";
}
export function categoryOf(ev: CalendarEvent, categories: Array<{ name: string; color: string }>): { name: string; color: string } | undefined {
const names = Object.keys(ev.categories ?? {});
for (const n of names) {
const c = categories.find((x) => x.name.toLowerCase() === n.toLowerCase());
if (c) return c;
}
return undefined;
}
export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: Props) {
const cal = useCalendar();
const [, navigate] = useLocation();
const categories = useSettings((s) => s.settings.eventCategories);
if (ctx.kind === "slot") {
const { start, end, allDay } = ctx;
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
</Popover>
);
}
const { inst } = ctx;
const ev = inst.event;
const baseId = ev.baseEventId ?? ev.id;
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
const currentCat = categoryOf(ev, categories);
const participants = Object.keys(ev.participants ?? {}).length;
const patch = async (p: Record<string, unknown>, msg: string) => {
try {
await cal.updateEvent(baseId, p, false);
toast.success(msg);
} catch (err) {
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Colour reset");
const setCategory = (cat: { name: string; color: string } | null) => {
const categoriesPatch = cat ? { [cat.name]: true } : null;
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
};
const duplicate = async () => {
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
try {
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success("Event duplicated");
} catch (err) {
toast.error((err as Error).message);
}
};
const del = async () => {
onClose();
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
try {
await cal.destroyEvent(baseId, participants > 1);
toast.success("Event deleted");
} catch (err) {
toast.error((err as Error).message);
}
};
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
{canEdit && (
<>
<MenuSep />
<MenuTitle><span className="row gap-4"><Tag size={12} /> Category</span></MenuTitle>
{categories.map((c) => (
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
))}
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button key={c} type="button" style={{ background: c, width: 26, height: 26, outline: ev.color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }} aria-label={c} onClick={() => { onClose(); setColor(c); }} />
))}
</div>
{ev.color && <MenuItem icon={<X size={16} />} label="Use calendar colour" onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
</>
)}
</Popover>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { Calendar } from "@/jmap/types";
import { useCalendar } from "@/store/calendar";
import { Dialog } from "@/ui/dialog";
import { ColorSwatches } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
const cal = useCalendar();
const [name, setName] = useState(calendar.name ?? "");
const [color, setColor] = useState(calendar.color ?? "#0f766e");
const [description, setDescription] = useState(calendar.description ?? "");
const [tz, setTz] = useState(calendar.timeZone ?? "");
const [avail, setAvail] = useState<Calendar["includeInAvailability"]>(calendar.includeInAvailability ?? "all");
const [busy, setBusy] = useState(false);
const save = async () => {
if (!name.trim()) return;
setBusy(true);
try {
const data: Partial<Calendar> = { name: name.trim(), color, description: description || null, timeZone: tz || null, includeInAvailability: avail };
if (calendar.id) await cal.updateCalendar(calendar.id, data);
else await cal.createCalendar(data);
toast.success("Calendar saved");
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>Save</button></>}>
<div className="field"><label>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Color</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>Description</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>Time zone</label>
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
<option value="">Default ({browserTimeZone})</option>
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="field"><label>Free/busy</label>
<select className="select" value={avail} onChange={(e) => setAvail(e.target.value as Calendar["includeInAvailability"])}>
<option value="all">Count all events as busy</option>
<option value="attending">Only events I'm attending</option>
<option value="none">Don't include in availability</option>
</select>
</div>
</Dialog>
);
}
@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import { formatMonthYear } from "@/lib/format";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
const cal = useCalendar();
const weekStart = useSettings((s) => s.settings.weekStart);
const parts = location.split("/");
const view = parts[2] || "week";
const dateStr = parts[3];
const selected = useMemo(() => (dateStr ? new Date(`${dateStr}T00:00:00`) : new Date()), [dateStr]);
const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu();
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
const [share, setShare] = useState<Calendar | null>(null);
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
const dow = useMemo(() => {
const names = ["S", "M", "T", "W", "T", "F", "S"];
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
}, [weekStart]);
if (!cal.available) return null;
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
return (
<div style={{ padding: "4px 8px" }}>
<div className="mini-cal">
<div className="mc-head">
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<span>{formatMonthYear(anchor)}</span>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
</div>
<div className="mc-grid">
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
{grid.map((d) => (
<div key={d.toISOString()} className={`mc-day ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""} ${isSameDay(d, selected) ? "selected" : ""} ${instances.some((i) => i.start < new Date(d.getTime() + 86400000) && i.end > d) ? "has-events" : ""}`} onClick={() => navigate(`/calendar/${view === "month" ? "day" : view}/${toLocalDateOnly(d)}`)}>
{d.getDate()}
</div>
))}
</div>
</div>
<div className="nav-section" style={{ paddingLeft: 4 }}>
<span>My calendars</span>
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
{c.isDefault && <Star size={12} className="faint" />}
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
</div>
))}
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
{menuCal && (
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
{editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</div>
);
}
+422
View File
@@ -0,0 +1,422 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react";
import { useCalendar, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
import { formatMonthYear, formatTime } from "@/lib/format";
import { Empty, useIsMobile } from "@/ui/misc";
import { keyboard } from "@/lib/keyboard";
import { EventPopover } from "./EventPopover";
import { EventEditor, type EditorInit } from "./EventEditor";
import type { Anchor } from "@/ui/popover";
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
type View = "month" | "week" | "day" | "agenda";
const HOUR_H = 48;
export function CalendarView({ view: viewParam, date }: { view?: string; date?: string }) {
const [, navigate] = useLocation();
const cal = useCalendar();
const settings = useSettings((s) => s.settings);
const isMobile = useIsMobile();
const view: View = (["month", "week", "day", "agenda"].includes(viewParam ?? "") ? viewParam : settings.calendarDefaultView) as View;
const anchor = useMemo(() => {
const d = date ? new Date(`${date}T00:00:00`) : new Date();
return Number.isNaN(d.getTime()) ? startOfDay(new Date()) : startOfDay(d);
}, [date]);
const [popover, setPopover] = useState<{ inst: EventInstance; anchor: Anchor } | null>(null);
const [editor, setEditor] = useState<EditorInit | null>(null);
const [ctx, setCtx] = useState<CalendarContext | null>(null);
const weekStart = settings.weekStart;
const effectiveView: View = isMobile && view === "week" ? "day" : view;
// Range to load
const range = useMemo(() => {
if (effectiveView === "month") {
const g = monthGrid(anchor, weekStart);
return { start: g[0]!, end: addDays(g[41]!, 1) };
}
if (effectiveView === "week") {
const s = startOfWeek(anchor, weekStart);
return { start: s, end: addDays(s, 7) };
}
if (effectiveView === "day") return { start: anchor, end: addDays(anchor, 1) };
return { start: anchor, end: addDays(anchor, 60) };
}, [effectiveView, anchor, weekStart]);
useEffect(() => {
if (cal.available) void cal.loadRange(range.start, range.end);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cal.available, range.start.getTime(), range.end.getTime()]);
const go = useCallback((v: View, d: Date) => navigate(`/calendar/${v}/${toLocalDateOnly(d)}`), [navigate]);
const step = (n: number) => {
if (effectiveView === "month") go(view, addMonths(anchor, n));
else if (effectiveView === "week") go(view, addDays(anchor, 7 * n));
else if (effectiveView === "day") go(view, addDays(anchor, n));
else go(view, addDays(anchor, 30 * n));
};
const openNew = useCallback(
(start?: Date, end?: Date, allDay = false) => {
const s = start ?? roundToNext(new Date(), 30);
const e = end ?? new Date(s.getTime() + settings.defaultEventDuration * 60_000);
setEditor({ start: s, end: e, allDay });
},
[settings.defaultEventDuration],
);
useEffect(() => {
const onNew = () => openNew();
window.addEventListener("ihm:new-event", onNew);
return () => window.removeEventListener("ihm:new-event", onNew);
}, [openNew]);
useEffect(
() =>
keyboard.pushScope("calendar", [
{ keys: "t", description: "Today", group: "Calendar", handler: () => go(view, new Date()) },
{ keys: "n", description: "Next period", group: "Calendar", handler: () => step(1) },
{ keys: "p", description: "Previous period", group: "Calendar", handler: () => step(-1) },
{ keys: "d", description: "Day view", group: "Calendar", handler: () => go("day", anchor) },
{ keys: "w", description: "Week view", group: "Calendar", handler: () => go("week", anchor) },
{ keys: "m", description: "Month view", group: "Calendar", handler: () => go("month", anchor) },
{ keys: "a", description: "Agenda view", group: "Calendar", handler: () => go("agenda", anchor) },
{ keys: "c", description: "New event", group: "Calendar", handler: () => openNew() },
]),
// eslint-disable-next-line react-hooks/exhaustive-deps
[view, anchor, openNew],
);
if (!cal.available) {
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title="Calendar is not available">This account does not have the JMAP calendars capability.</Empty></div>;
}
const title =
effectiveView === "month" ? formatMonthYear(anchor)
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
const onEvent = (inst: EventInstance, el: Element) => {
const r = el.getBoundingClientRect();
setPopover({ inst, anchor: { x: r.left, y: r.top, w: r.width, h: r.height } });
};
const onEventContext = (inst: EventInstance, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPopover(null);
setCtx({ kind: "event", inst, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
};
const onSlotContext = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => {
e.preventDefault();
setCtx({ kind: "slot", start, end, allDay, anchor: { x: e.clientX, y: e.clientY, w: 0, h: 0 } });
};
return (
<div className="cal-main">
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label="Previous"><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
<h2 className="truncate">{title}</h2>
<span className="spacer" />
{cal.loading && <span className="spinner" />}
<div className="view-switch">
{(["day", "week", "month", "agenda"] as View[]).filter((v) => !(isMobile && v === "week")).map((v) => (
<button key={v} className={effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{v[0]!.toUpperCase() + v.slice(1)}</button>
))}
</div>
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> Event</button>}
</div>
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
{effectiveView === "month" && <MonthView anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
{isMobile && <button className="fab" aria-label="New event" onClick={() => openNew()}><Plus size={24} /></button>}
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
</div>
);
}
/* ---------------- Month ---------------- */
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void;
function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void }) {
const cal = useCalendar();
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
const maxPer = 4;
return (
<div className="month-grid">
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
{weeks.map((days, wi) => (
<div key={wi} className="week-row">
{days.map((d) => {
const dayEnd = addDays(d, 1);
const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
const shown = evs.slice(0, maxPer);
return (
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
</div>
);
})}
</div>
))}
</div>
);
}
function statusClass(i: EventInstance): string {
const ev = i.event;
const mine = useCalendar.getState().identities;
const ids = mine.flatMap((m) => [m.calendarAddress.toLowerCase(), ...Object.values(m.sendTo ?? {}).map((x) => x.toLowerCase())]);
let my: string | undefined;
for (const p of Object.values(ev.participants ?? {})) {
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
if (addrs.some((a) => ids.includes(a))) my = p.participationStatus;
}
if (ev.status === "cancelled") return "cancelled";
if (my === "declined") return "declined";
if (my === "tentative" || my === "needs-action" || ev.status === "tentative") return "tentative";
return "";
}
function useEventColor() {
const categories = useSettings((s) => s.settings.eventCategories);
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories);
}
function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void }) {
const color = useEventColor()(inst);
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
return (
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)}`} style={{ background: color, borderColor: color }} onClick={(e) => { e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
{!spansDay && <span className="ev-dot" style={{ background: color }} />}
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
<span className="truncate">{inst.event.title || "(untitled)"}</span>
</div>
);
}
/* ---------------- Week / Day ---------------- */
function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; workStart: number; workEnd: number }) {
const cal = useCalendar();
const colorOf = useEventColor();
const scrollRef = useRef<HTMLDivElement>(null);
const start = days[0]!;
const end = addDays(days[days.length - 1]!, 1);
const instances = cal.instancesIn(start, end);
const [now, setNow] = useState(new Date());
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null);
useEffect(() => {
const t = window.setInterval(() => setNow(new Date()), 60_000);
return () => window.clearInterval(t);
}, []);
useEffect(() => {
// scroll to 7am-ish on mount
if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, (Math.min(workStart, 8) - 0.5) * HOUR_H);
}, [workStart, days.length]);
const allDay = (d: Date) => instances.filter((i) => (i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
const timed = (d: Date) => instances.filter((i) => !(i.allDay || i.end.getTime() - i.start.getTime() >= DAY_MS) && i.start < addDays(d, 1) && i.end > d);
const minutesFromEvent = (e: React.MouseEvent, col: HTMLElement) => {
const r = col.getBoundingClientRect();
const y = e.clientY - r.top + 0; // col is full height
return Math.max(0, Math.min(24 * 60, Math.round((y / HOUR_H) * 60 / 15) * 15));
};
return (
<div className="week-view" style={{ "--cols": days.length } as React.CSSProperties}>
<div className="week-head">
<div />
{days.map((d) => (
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
<div className="dnum">{d.getDate()}</div>
</div>
))}
</div>
<div className="week-allday">
<div className="ad-label">all-day</div>
{days.map((d) => (
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
</div>
))}
</div>
<div className="week-scroll" ref={scrollRef}>
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
<div className="time-col">
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
</div>
{days.map((d) => {
const evs = layoutOverlaps(timed(d), d);
const today = isToday(d);
const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H;
return (
<div
key={d.toISOString()}
className={`day-col ${today ? "today" : ""}`}
onMouseDown={(e) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest(".ev-block")) return;
const m = minutesFromEvent(e, e.currentTarget);
setDrag({ day: d, startMin: m, endMin: m + 30 });
}}
onMouseMove={(e) => {
if (!drag || !isSameDay(drag.day, d)) return;
const m = minutesFromEvent(e, e.currentTarget);
setDrag({ ...drag, endMin: Math.max(drag.startMin + 15, m) });
}}
onMouseUp={() => {
if (!drag || !isSameDay(drag.day, d)) return;
const s = new Date(d.getTime() + drag.startMin * 60_000);
const e2 = new Date(d.getTime() + drag.endMin * 60_000);
setDrag(null);
onCreate(s, e2, false);
}}
onMouseLeave={() => { if (drag && isSameDay(drag.day, d)) { const s = new Date(d.getTime() + drag.startMin * 60_000); const e2 = new Date(d.getTime() + drag.endMin * 60_000); setDrag(null); onCreate(s, e2, false); } }}
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest(".ev-block")) return;
const m = minutesFromEvent(e, e.currentTarget);
const st = new Date(d.getTime() + Math.floor(m / 30) * 30 * 60_000);
onSlotContext(st, new Date(st.getTime() + 60 * 60_000), false, e);
}}
>
<div className="work-hours" style={{ top: workStart * HOUR_H, height: Math.max(0, workEnd - workStart) * HOUR_H }} />
{[...Array(24)].map((_, h) => <div key={h} className="hour-line" style={{ top: h * HOUR_H }} />)}
{[...Array(24)].map((_, h) => <div key={`h${h}`} className="half-line" style={{ top: h * HOUR_H + HOUR_H / 2 }} />)}
{today && <div className="now-line" style={{ top: nowTop }} />}
{evs.map(({ inst, top, height, left, width }) => {
const color = colorOf(inst);
return (
<div key={inst.key} className={`ev-block ${statusClass(inst)}`} style={{ top, height: Math.max(height, 18), left: `${left}%`, width: `calc(${width}% - 3px)`, background: color }} onClick={(e) => { e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}>
<div className="ev-title">{inst.event.title || "(untitled)"}</div>
{height > 30 && <div className="ev-time">{formatTime(inst.start)} {formatTime(inst.end)}</div>}
</div>
);
})}
{drag && isSameDay(drag.day, d) && (
<div className="ev-block draft-new" style={{ top: (drag.startMin / 60) * HOUR_H, height: ((drag.endMin - drag.startMin) / 60) * HOUR_H, left: 0, width: "calc(100% - 3px)", background: "var(--accent)" }}>
<div className="ev-title">(new event)</div>
<div className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
/** Simple column layout for overlapping events. */
function layoutOverlaps(evs: EventInstance[], day: Date): Array<{ inst: EventInstance; top: number; height: number; left: number; width: number }> {
const dayStart = day.getTime();
const dayEnd = dayStart + DAY_MS;
const items = evs
.map((inst) => {
const s = Math.max(inst.start.getTime(), dayStart);
const e = Math.min(inst.end.getTime(), dayEnd);
return { inst, s, e, col: 0, cols: 1 };
})
.sort((a, b) => a.s - b.s || b.e - a.e);
// Greedy column assignment within clusters
const clusters: Array<typeof items> = [];
let cur: typeof items = [];
let curEnd = -1;
for (const it of items) {
if (cur.length && it.s >= curEnd) {
clusters.push(cur);
cur = [];
curEnd = -1;
}
cur.push(it);
curEnd = Math.max(curEnd, it.e);
}
if (cur.length) clusters.push(cur);
for (const cl of clusters) {
const colEnds: number[] = [];
for (const it of cl) {
let c = colEnds.findIndex((end) => end <= it.s);
if (c < 0) {
c = colEnds.length;
colEnds.push(0);
}
colEnds[c] = it.e;
it.col = c;
}
for (const it of cl) it.cols = colEnds.length;
}
return items.map((it) => ({
inst: it.inst,
top: ((it.s - dayStart) / 3_600_000) * HOUR_H,
height: ((it.e - it.s) / 3_600_000) * HOUR_H,
left: (it.col / it.cols) * 100,
width: 100 / it.cols,
}));
}
/* ---------------- Agenda ---------------- */
function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx }) {
const cal = useCalendar();
const colorOf = useEventColor();
const end = addDays(start, 60);
const instances = cal.instancesIn(start, end);
const byDay = useMemo(() => {
const map = new Map<string, { day: Date; items: EventInstance[] }>();
for (const i of instances) {
let d = startOfDay(i.start < start ? start : i.start);
const last = startOfDay(new Date(i.end.getTime() - 1));
while (d <= last && d < end) {
const k = toLocalDateOnly(d);
const e = map.get(k) ?? { day: new Date(d), items: [] };
e.items.push(i);
map.set(k, e);
d = addDays(d, 1);
if (!i.allDay && isSameDay(i.start, i.end)) break;
}
}
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
}, [instances, start, end]);
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title="Nothing scheduled">No events in the next 60 days.</Empty>;
return (
<div className="agenda">
{byDay.map(({ day, items }) => (
<div key={day.toISOString()} className="agenda-day">
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
{day.toLocaleDateString(undefined, { weekday: "long" })}
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
</div>
<div>
{items.map((i) => (
<div key={i.key + day.toISOString()} className={`agenda-ev ${statusClass(i)}`} onClick={(e) => onEvent(i, e.currentTarget)} onContextMenu={(e) => onEventContext(i, e)}>
<span className="ev-dot" style={{ background: colorOf(i) }} />
<span className="ev-when">{i.allDay ? "All day" : `${formatTime(i.start)} ${formatTime(i.end)}`}</span>
<span className="grow truncate">{i.event.title || "(untitled)"}</span>
{Object.values(i.event.locations ?? {})[0]?.name && <span className="hint truncate" style={{ maxWidth: 200 }}>{Object.values(i.event.locations ?? {})[0]!.name}</span>}
</div>
))}
</div>
</div>
))}
</div>
);
}
export { endOfDay };
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useMemo, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
import { Dialog } from "@/ui/dialog";
import { ColorSwatches, Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
export interface EditorInit {
event?: CalendarEvent;
start: Date;
end: Date;
allDay: boolean;
}
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
const cal = useCalendar();
const settings = useSettings((s) => s.settings);
const session = useSession((s) => s.session);
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
const editing = Boolean(init.event);
// Load base event for recurring instances
useEffect(() => {
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
else if (!init.event) setBase(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [init.event?.id]);
if (base === undefined) return null;
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
}
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
const cal = useCalendar();
const contacts = useContacts();
const ev = base;
const calendars = Object.values(cal.calendars).filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn);
const initialCal = ev ? Object.keys(ev.calendarIds)[0] : (calendars.find((c) => c.isDefault)?.id ?? calendars[0]?.id);
const evTz = ev?.timeZone ?? settingsTz;
const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start;
const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end;
const [title, setTitle] = useState(ev?.title ?? "");
const [calendarId, setCalendarId] = useState(initialCal ?? "");
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
const [start, setStart] = useState(baseStart);
const [end, setEnd] = useState(baseEnd);
const [tz, setTz] = useState(evTz);
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? "");
const [description, setDescription] = useState(ev?.description ?? "");
const [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
const [color, setColor] = useState<string | null>(ev?.color ?? null);
const categories = useSettings((s) => s.settings.eventCategories);
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
const [alerts, setAlerts] = useState<number[]>(() => {
const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0);
if (ev) return a;
return defaultAlert >= 0 ? [defaultAlert] : [];
});
const myKeys = ev ? myParticipantKeys(ev, cal.identities) : [];
const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
Object.entries(ev?.participants ?? {})
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" }))
.filter((a) => a.email),
);
const [sendInvites, setSendInvites] = useState(true);
const [busy, setBusy] = useState(false);
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0];
const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : "");
const myPlainEmail = myAddress.replace(/^mailto:/i, "");
// Free/busy lookup for attendees that are directory principals
useEffect(() => {
if (!attendees.length || !contacts.principalsLoaded) {
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return;
}
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
let cancelled = false;
(async () => {
const out: Record<string, BusyPeriod[]> = {};
for (const a of attendees) {
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
if (!p) continue;
try {
out[a.email] = await cal.availability(p.id, dayStart, dayEnd);
} catch {
/* ignore */
}
}
if (!cancelled) setFb(out);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attendees.map((a) => a.email).join(","), start.getTime(), contacts.principalsLoaded]);
const onStartChange = (d: Date) => {
if (Number.isNaN(d.getTime())) return;
const dur = end.getTime() - start.getTime();
setStart(d);
setEnd(new Date(d.getTime() + Math.max(dur, allDay ? DAY_MS : 15 * 60_000)));
};
const save = async () => {
if (!calendarId) {
toast.error("Choose a calendar");
return;
}
if (end <= start) {
toast.error("End must be after start");
return;
}
setBusy(true);
try {
const s = allDay ? new Date(start.getFullYear(), start.getMonth(), start.getDate()) : start;
let e = allDay ? new Date(end.getFullYear(), end.getMonth(), end.getDate()) : end;
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
const participants: Record<string, JSCalendarParticipant> = {};
if (attendees.length && myAddress) {
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false };
for (const a of attendees) {
// preserve existing status if the attendee was already there
const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase());
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true };
}
}
const alertObj: Record<string, JSCalendarAlert> = {};
for (const m of alerts) alertObj[newKey("a")] = { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: formatDuration(-m * 60), relativeTo: "start" }, action: "display" };
const obj: Record<string, unknown> = {
title: title.trim() || "(untitled)",
description: description.trim() || undefined,
showWithoutTime: allDay,
start: allDay ? `${toLocalDateOnly(s)}T00:00:00` : dateToZonedLocal(s, tz),
timeZone: allDay ? null : tz,
duration: formatDuration(Math.round((e.getTime() - s.getTime()) / 1000)),
locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined,
virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined,
participants: Object.keys(participants).length ? participants : undefined,
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined,
alerts: Object.keys(alertObj).length ? alertObj : undefined,
useDefaultAlerts: false,
recurrenceRules: rule ? [rule] : undefined,
status,
privacy,
freeBusyStatus: freeBusy,
color: color ?? (category ? categories.find((c) => c.name === category)?.color : undefined),
categories: category ? { [category]: true } : undefined,
};
const invites = sendInvites && attendees.length > 0;
if (ev) {
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
await cal.updateEvent(ev.id, patch, invites);
toast.success("Event updated");
} else {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites);
toast.success(invites ? "Event created and invitations sent" : "Event created");
}
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const };
const dayWindow = useMemo(() => {
const ds = new Date(start);
ds.setHours(0, 0, 0, 0);
return { ds, de: new Date(ds.getTime() + DAY_MS) };
}, [start]);
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<div className="event-form">
{init.event?.baseEventId && <div className="info-box mb-16">This is a recurring event changes apply to the whole series.</div>}
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="time-row mb-8">
{allDay ? (
<>
<input className="input" type="date" value={toLocalDateOnly(start)} onChange={(e) => onStartChange(new Date(`${e.target.value}T00:00:00`))} />
<span className="muted center">to</span>
<input className="input" type="date" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(e) => setEnd(new Date(new Date(`${e.target.value}T00:00:00`).getTime() + DAY_MS))} />
</>
) : (
<>
<input className="input" type="datetime-local" value={toInputDateTime(start)} onChange={(e) => onStartChange(fromInputDateTime(e.target.value))} />
<span className="muted center">to</span>
<input className="input" type="datetime-local" value={toInputDateTime(end)} onChange={(e) => setEnd(fromInputDateTime(e.target.value))} />
</>
)}
</div>
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
{!allDay && (
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title="Time zone">
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
)}
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
<option value="weekdays">Every weekday</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
</select>
</div>
{preset === "custom" && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="row wrap" style={{ gap: 8 }}>
<span>Repeat every</span>
<input className="input" type="number" min={1} style={{ width: 70 }} value={customRule.interval ?? 1} onChange={(e) => setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
<select className="select" style={{ width: "auto" }} value={customRule.frequency} onChange={(e) => setRule({ ...customRule, frequency: e.target.value as JSCalendarRecurrenceRule["frequency"], byDay: e.target.value === "weekly" ? customRule.byDay : undefined, byMonthDay: e.target.value === "monthly" ? [start.getDate()] : undefined })}>
<option value="daily">day(s)</option><option value="weekly">week(s)</option><option value="monthly">month(s)</option><option value="yearly">year(s)</option>
</select>
</div>
{customRule.frequency === "weekly" && (
<div className="row" style={{ gap: 4, marginTop: 8 }}>
{WEEKDAYS.map((w) => {
const on = customRule.byDay?.some((d) => d.day === w.key);
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
})}
</div>
)}
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
<span>Ends</span>
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
</select>
{customRule.until && <input className="input" type="date" style={{ width: "auto" }} value={customRule.until.slice(0, 10)} onChange={(e) => setRule({ ...customRule, until: `${e.target.value}T23:59:59` })} />}
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
</div>
<div className="hint mt-8">{describeRule(customRule)}</div>
</div>
)}
<div className="field-row">
<div className="field"><label>Calendar</label>
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Location</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Add location" /></div>
</div>
<div className="field"><label>Meeting link</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder="https://meet.example.com/…" /></div>
<div className="field">
<label><Users size={13} /> Guests</label>
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
<RecipientInput value={attendees} onChange={setAttendees} placeholder="Add guests by name or email" />
</div>
{attendees.length > 0 && (
<>
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {start.toLocaleDateString()}</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
<div className="fb-bar">
{fb[a.email]!.map((b, i) => {
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
if (be <= bs) return null;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
})}
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
<div className="field"><label>Description</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
<div className="field">
<label>Reminders</label>
<div className="alerts-list">
{alerts.map((m, i) => (
<div key={i} className="row">
<select className="select" style={{ width: "auto" }} value={String(m)} onChange={(e) => setAlerts(alerts.map((x, j) => (j === i ? Number(e.target.value) : x)))}>
{[...new Set([...ALERT_OPTIONS, m])].sort((a, b) => a - b).map((o) => <option key={o} value={o}>{o === 0 ? "At time of event" : `${humanDuration(o * 60)} before`}</option>)}
</select>
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label="Remove reminder"><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => setShowMore((v) => !v)}>{showMore ? "Fewer options" : "More options"}</button>
{showMore && (
<div className="mt-8">
<div className="field-row">
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
</div>
<div className="field"><label>Category</label>
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">None</option>
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Color</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
</div>
)}
</div>
</Dialog>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from "react";
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
import { useCalendar, myParticipantKeys, type EventInstance } from "@/store/calendar";
import { Popover, type Anchor } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { formatTimeRange, humanDuration, parseDuration } from "@/lib/dates";
import { describeRule } from "@/lib/recurrence";
import { useCompose } from "@/store/compose";
import { useSettings } from "@/store/settings";
import { categoryOf, eventColor } from "./CalendarContextMenu";
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
const cal = useCalendar();
const ev = inst.event;
const [busy, setBusy] = useState(false);
const categories = useSettings((s) => s.settings.eventCategories);
const color = eventColor(ev, inst.calendar?.color, categories);
const category = categoryOf(ev, categories);
const participants = Object.entries(ev.participants ?? {});
const myKeys = myParticipantKeys(ev, cal.identities);
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
const baseId = ev.baseEventId ?? ev.id;
const location = Object.values(ev.locations ?? {})[0];
const vloc = Object.values(ev.virtualLocations ?? {})[0];
const alerts = Object.values(ev.alerts ?? {});
const openCompose = useCompose((s) => s.open);
const del = async () => {
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
if (!ok) return;
setBusy(true);
try {
await cal.destroyEvent(baseId, participants.length > 1);
toast.success("Event deleted");
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
setBusy(true);
try {
await cal.rsvp(baseId, status);
toast.success("Response sent");
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
</div>
<h3>{ev.title || "(untitled)"}</h3>
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
{ev.recurrenceRules?.[0] && <div className="ev-line"><Repeat size={15} /><span>{describeRule(ev.recurrenceRules[0])}</span></div>}
{location?.name && <div className="ev-line"><MapPin size={15} /><span>{location.name}</span></div>}
{vloc?.uri && <div className="ev-line"><Link2 size={15} /><a href={vloc.uri} target="_blank" rel="noreferrer" className="truncate">{vloc.name || vloc.uri}</a></div>}
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
<div className="ev-line"><CalIcon size={15} /><span>{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}</span></div>
{participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => (
<div key={k} className="participant-row">
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
<span className="truncate">{p.name || p.email || Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "")}</span>
{p.roles?.owner && <span className="hint">organizer</span>}
{p.roles?.optional && <span className="hint">optional</span>}
</div>
))}
</div>
</div>
)}
{myKeys.length > 0 && !isOrganizer && (
<div className="row" style={{ marginTop: 10, gap: 6 }}>
<span className="hint">Going?</span>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
</div>
)}
</Popover>
);
}