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, isRecurring, isOccurrence, type EventInstance, type EventScope } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { formatDayMonth } from "@/lib/datetime";
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
import { t } from "@/lib/i18n";
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 (
} label={allDay ? t("New all-day event on {date}", { date: formatDayMonth(start) }) : t("New event at {time}", { time: formatTime(start) })} onClick={() => onCreate(start, end, allDay)} />
{!allDay && } label={t("New all-day event")} onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
} label={t("Go to day")} onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
} label={t("Go to week")} onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
);
}
const { inst } = ctx;
const ev = inst.event;
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, msg: string) => {
const scope = await askEditScope(ev);
if (!scope) return;
try {
const dropped = await runScoped(scope, (s) => cal.updateEvent(ev, p, false, s));
if (!dropped) return;
// A per-occurrence change can be accepted in part. Say which part.
toast.success(droppedMessage(dropped) ?? (scope === "occurrence" ? `${msg} for this date` : msg));
} catch (err) {
toast.error((err as Error).message);
}
};
const setColor = (color: string | null) => void patch({ color }, color ? t("Colour updated") : t("Custom colour removed"));
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 ? t("Categorised as {name}", { name: cat.name }) : t("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;
try {
await cal.createEvent({ ...rest, title: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
toast.success(t("Event duplicated"));
} catch (err) {
toast.error((err as Error).message);
}
};
const del = async () => {
onClose();
let scope: EventScope | null = "series";
if (isRecurring(ev) && isOccurrence(ev)) {
scope = await askDeleteScope(ev);
} else if (!(await confirmDialog({ title: t("Delete this event?"), confirmLabel: t("Delete"), danger: true }))) {
scope = null;
}
if (!scope) return;
try {
await runScoped(scope, (s) => cal.destroyEvent(ev, participants > 1, s));
toast.success(scope === "occurrence" ? "Occurrence deleted" : "Event deleted");
} catch (err) {
toast.error((err as Error).message);
}
};
return (
} label={t("Open")} onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && } label={t("Edit…")} onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && } label={t("Duplicate")} onClick={() => { onClose(); void duplicate(); }} />}
{canEdit && (
<>
{t("Category")}
{categories.map((c) => (
);
}