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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user