@rightkit/workspace-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +45 -2
- package/dist/client.d.ts +190 -0
- package/dist/client.js +2 -0
- package/dist/env.d.ts +59 -0
- package/dist/hooks/useDrive.d.ts +26 -0
- package/dist/hooks/useDrive.js +158 -0
- package/dist/host.d.ts +109 -0
- package/dist/host.js +33 -0
- package/dist/icons.d.ts +34 -0
- package/dist/icons.js +33 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +24 -0
- package/dist/lib/avatar.d.ts +4 -0
- package/dist/lib/avatar.js +13 -0
- package/dist/lib/calendarUtils.d.ts +11 -0
- package/dist/lib/calendarUtils.js +42 -0
- package/dist/lib/vcard.d.ts +12 -0
- package/dist/lib/vcard.js +86 -0
- package/dist/views/CalendarEventFormModal.d.ts +10 -0
- package/dist/views/CalendarEventFormModal.js +19 -0
- package/dist/views/CalendarEventPopover.d.ts +10 -0
- package/dist/views/CalendarEventPopover.js +10 -0
- package/dist/views/CalendarGrids.d.ts +16 -0
- package/dist/views/CalendarGrids.js +106 -0
- package/dist/views/CalendarView.d.ts +3 -0
- package/dist/views/CalendarView.js +332 -0
- package/dist/views/ContactsView.d.ts +3 -0
- package/dist/views/ContactsView.js +227 -0
- package/dist/views/ContactsView.types.d.ts +8 -0
- package/dist/views/ContactsView.types.js +7 -0
- package/dist/views/DriveFileMenu.d.ts +14 -0
- package/dist/views/DriveFileMenu.js +32 -0
- package/dist/views/DrivePreviewModal.d.ts +9 -0
- package/dist/views/DrivePreviewModal.js +11 -0
- package/dist/views/DriveView.d.ts +3 -0
- package/dist/views/DriveView.js +185 -0
- package/dist/views/MeetView.d.ts +1 -0
- package/dist/views/MeetView.js +81 -0
- package/dist/views/TasksView.d.ts +3 -0
- package/dist/views/TasksView.js +156 -0
- package/dist/views/driveFileGlyph.d.ts +7 -0
- package/dist/views/driveFileGlyph.js +19 -0
- package/dist/views/nav/CalendarNav.d.ts +1 -0
- package/dist/views/nav/CalendarNav.js +93 -0
- package/dist/views/nav/ContactsNav.d.ts +1 -0
- package/dist/views/nav/ContactsNav.js +22 -0
- package/dist/views/nav/DriveNav.d.ts +1 -0
- package/dist/views/nav/DriveNav.js +44 -0
- package/dist/views/nav/TasksNav.d.ts +1 -0
- package/dist/views/nav/TasksNav.js +22 -0
- package/package.json +70 -3
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, Dialog } from "@rightkit/react-shell/react";
|
|
3
|
+
import { eventDay, hhmm } from "../lib/calendarUtils.js";
|
|
4
|
+
import { useWorkspaceClient } from "../host.js";
|
|
5
|
+
export function CalendarEventPopover({ openEvent, setOpenEvent, openEventForm, deleteEv, rsvp, }) {
|
|
6
|
+
const { openExternal } = useWorkspaceClient();
|
|
7
|
+
return (_jsxs(Dialog, { title: openEvent.summary || "(no title)", width: 420, onClose: () => setOpenEvent(null), children: [_jsxs("p", { className: "event-when", children: [openEvent.all_day
|
|
8
|
+
? new Date(eventDay(openEvent)).toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" }) + " · all day"
|
|
9
|
+
: `${new Date(openEvent.start).toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" })} · ${hhmm(openEvent.start)}${openEvent.end ? `–${hhmm(openEvent.end)}` : ""}`, openEvent.is_recurring ? " · repeats" : ""] }), openEvent.location && _jsx("p", { className: "hint", children: openEvent.location }), openEvent.description && _jsx("p", { className: "event-desc", children: openEvent.description }), openEvent.attendees.length > 0 && (_jsxs("p", { className: "hint", children: [openEvent.attendees.length, " guest", openEvent.attendees.length > 1 ? "s" : ""] })), _jsxs("div", { className: "ws-detail-actions", children: [openEvent.meet_link && (_jsx(Button, { variant: "primary", size: "sm", onClick: () => openExternal(openEvent.meet_link), children: "Join Meet" })), openEvent.my_response && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "accepted"), children: "Yes" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "tentative"), children: "Maybe" }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => rsvp(openEvent, "declined"), children: "No" })] })), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => openEventForm(openEvent), children: "Edit" }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => deleteEv(openEvent), children: "Delete" })] })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CalEvent } from "../client.js";
|
|
2
|
+
export declare function MonthGrid({ cursor, events, today, onDay, onEvent }: {
|
|
3
|
+
cursor: number;
|
|
4
|
+
events: CalEvent[];
|
|
5
|
+
today: number;
|
|
6
|
+
onDay: (ms: number) => void;
|
|
7
|
+
onEvent: (e: CalEvent) => void;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
9
|
+
export declare function TimeGrid({ days, events, today, nowMin, onSlot, onEvent }: {
|
|
10
|
+
days: number[];
|
|
11
|
+
events: CalEvent[];
|
|
12
|
+
today: number;
|
|
13
|
+
nowMin: number;
|
|
14
|
+
onSlot: (ms: number) => void;
|
|
15
|
+
onEvent: (e: CalEvent) => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from "@rightkit/react-shell/react";
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { DAY, WEEKDAY_LABELS, eventDay, hhmm, sameDay, startOfWeek } from "../lib/calendarUtils.js";
|
|
5
|
+
export function MonthGrid({ cursor, events, today, onDay, onEvent }) {
|
|
6
|
+
const d = new Date(cursor);
|
|
7
|
+
const first = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
8
|
+
const gridStart = startOfWeek(first);
|
|
9
|
+
const month = d.getMonth();
|
|
10
|
+
const byDay = useMemo(() => {
|
|
11
|
+
const m = new Map();
|
|
12
|
+
for (const e of events) {
|
|
13
|
+
const k = eventDay(e);
|
|
14
|
+
(m.get(k) ?? m.set(k, []).get(k)).push(e);
|
|
15
|
+
}
|
|
16
|
+
return m;
|
|
17
|
+
}, [events]);
|
|
18
|
+
return (_jsxs("div", { className: "month-grid", children: [_jsx("div", { className: "month-dow", children: WEEKDAY_LABELS.map((w) => _jsx("div", { className: "dow-cell", children: w }, w)) }), _jsx("div", { className: "month-cells", children: Array.from({ length: 42 }, (_, i) => {
|
|
19
|
+
const ms = gridStart + i * DAY;
|
|
20
|
+
const day = new Date(ms);
|
|
21
|
+
const inMonth = day.getMonth() === month;
|
|
22
|
+
const isToday = ms === today;
|
|
23
|
+
const dayEvents = byDay.get(ms) ?? [];
|
|
24
|
+
return (_jsxs("div", { className: `month-cell ${inMonth ? "" : "out"} ${isToday ? "today" : ""}`, onClick: () => onDay(ms), children: [_jsx(Button, { variant: "ghost", className: "hit-overlay", "aria-label": day.toDateString() }), _jsx("div", { className: "month-daynum", children: _jsx("span", { className: isToday ? "today-pill" : "", children: day.getDate() }) }), _jsxs("div", { className: "month-events", children: [dayEvents.slice(0, 4).map((e) => (_jsxs(Button, { variant: "ghost", className: `cal-chip ${e.all_day ? "allday" : ""}`, title: e.summary, onClick: (ev) => { ev.stopPropagation(); onEvent(e); }, children: [!e.all_day && _jsx("span", { className: "chip-dot" }), !e.all_day && _jsx("span", { className: "chip-time", children: hhmm(e.start) }), _jsx("span", { className: "chip-name", children: e.summary || "(no title)" })] }, e.id))), dayEvents.length > 4 && _jsxs("span", { className: "chip-more", children: ["+", dayEvents.length - 4, " more"] })] })] }, i));
|
|
25
|
+
}) })] }));
|
|
26
|
+
}
|
|
27
|
+
const HOUR_PX = 48;
|
|
28
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
29
|
+
const formatHour = (h) => `${(h % 12) || 12} ${h < 12 ? "AM" : "PM"}`;
|
|
30
|
+
const minOf = (iso) => {
|
|
31
|
+
const d = new Date(iso);
|
|
32
|
+
return d.getHours() * 60 + d.getMinutes();
|
|
33
|
+
};
|
|
34
|
+
function packLanes(evs) {
|
|
35
|
+
const res = new Map();
|
|
36
|
+
const items = evs
|
|
37
|
+
.map((e) => {
|
|
38
|
+
const s = new Date(e.start).getTime();
|
|
39
|
+
const en = e.end ? new Date(e.end).getTime() : s + 3600000;
|
|
40
|
+
return { id: e.id, s, en: Math.max(en, s + 15 * 60000) };
|
|
41
|
+
})
|
|
42
|
+
.sort((a, b) => a.s - b.s);
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < items.length) {
|
|
45
|
+
let clusterEnd = items[i].en;
|
|
46
|
+
const cluster = [items[i]];
|
|
47
|
+
let j = i;
|
|
48
|
+
while (j + 1 < items.length && items[j + 1].s < clusterEnd) {
|
|
49
|
+
j++;
|
|
50
|
+
cluster.push(items[j]);
|
|
51
|
+
clusterEnd = Math.max(clusterEnd, items[j].en);
|
|
52
|
+
}
|
|
53
|
+
const laneEnds = [];
|
|
54
|
+
for (const it of cluster) {
|
|
55
|
+
let lane = laneEnds.findIndex((end) => end <= it.s);
|
|
56
|
+
if (lane === -1) {
|
|
57
|
+
lane = laneEnds.length;
|
|
58
|
+
laneEnds.push(it.en);
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
laneEnds[lane] = it.en;
|
|
62
|
+
res.set(it.id, { lane, lanes: 0 });
|
|
63
|
+
}
|
|
64
|
+
for (const it of cluster)
|
|
65
|
+
res.get(it.id).lanes = laneEnds.length;
|
|
66
|
+
i = j + 1;
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
export function TimeGrid({ days, events, today, nowMin, onSlot, onEvent }) {
|
|
71
|
+
const bodyRef = useRef(null);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (bodyRef.current)
|
|
74
|
+
bodyRef.current.scrollTop = 7 * HOUR_PX;
|
|
75
|
+
}, []);
|
|
76
|
+
const cols = `64px repeat(${days.length}, 1fr)`;
|
|
77
|
+
return (_jsxs("div", { className: "time-grid", children: [_jsxs("div", { className: "tg-head", style: { gridTemplateColumns: cols }, children: [_jsx("div", { className: "tg-corner" }), days.map((ms) => {
|
|
78
|
+
const d = new Date(ms);
|
|
79
|
+
return (_jsxs("div", { className: `tg-day-head ${ms === today ? "today" : ""}`, children: [_jsx("span", { className: "tg-dow", children: WEEKDAY_LABELS[d.getDay()] }), _jsx("span", { className: `tg-daynum ${ms === today ? "today-pill" : ""}`, children: d.getDate() })] }, ms));
|
|
80
|
+
})] }), _jsxs("div", { className: "tg-allday", style: { gridTemplateColumns: cols }, children: [_jsx("div", { className: "tg-corner tg-allday-label", children: "all-day" }), days.map((ms) => {
|
|
81
|
+
const ad = events.filter((e) => e.all_day && sameDay(eventDay(e), ms));
|
|
82
|
+
return (_jsx("div", { className: "tg-allday-col", children: ad.map((e) => (_jsx(Button, { variant: "ghost", className: "cal-chip allday", title: e.summary, onClick: () => onEvent(e), children: _jsx("span", { className: "chip-name", children: e.summary || "(no title)" }) }, e.id))) }, ms));
|
|
83
|
+
})] }), _jsxs("div", { className: "tg-body", ref: bodyRef, style: { gridTemplateColumns: cols }, children: [_jsx("div", { className: "tg-gutter", children: HOURS.map((h) => (_jsx("div", { className: "tg-hour-label", style: { height: HOUR_PX }, children: h === 0 ? "" : formatHour(h) }, h))) }), days.map((ms) => {
|
|
84
|
+
const dayEvents = events
|
|
85
|
+
.filter((e) => !e.all_day && sameDay(eventDay(e), ms))
|
|
86
|
+
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
87
|
+
const lanes = packLanes(dayEvents);
|
|
88
|
+
return (_jsxs("div", { className: `tg-col ${ms === today ? "today" : ""}`, style: { height: 24 * HOUR_PX }, onClick: (ev) => {
|
|
89
|
+
const rect = ev.currentTarget.getBoundingClientRect();
|
|
90
|
+
const hour = Math.max(0, Math.min(23, Math.floor((ev.clientY - rect.top) / HOUR_PX)));
|
|
91
|
+
onSlot(ms + hour * 3600000);
|
|
92
|
+
}, children: [HOURS.map((h) => (_jsx("div", { className: "tg-hline", style: { top: h * HOUR_PX } }, h))), ms === today && _jsx("div", { className: "tg-now", style: { top: (nowMin / 60) * HOUR_PX } }), dayEvents.map((e) => {
|
|
93
|
+
const startMin = minOf(e.start);
|
|
94
|
+
const rawEnd = e.end ? minOf(e.end) + (new Date(e.end).getDate() !== new Date(e.start).getDate() ? 1440 : 0) : startMin + 60;
|
|
95
|
+
const endMin = Math.max(startMin + 20, rawEnd);
|
|
96
|
+
const li = lanes.get(e.id) ?? { lane: 0, lanes: 1 };
|
|
97
|
+
const w = 100 / li.lanes;
|
|
98
|
+
return (_jsxs(Button, { variant: "ghost", className: "tg-event", style: {
|
|
99
|
+
top: (startMin / 60) * HOUR_PX,
|
|
100
|
+
height: ((endMin - startMin) / 60) * HOUR_PX,
|
|
101
|
+
left: `calc(${li.lane * w}% + 2px)`,
|
|
102
|
+
width: `calc(${w}% - 4px)`,
|
|
103
|
+
}, title: e.summary, onClick: (ev) => { ev.stopPropagation(); onEvent(e); }, children: [_jsx("span", { className: "tg-event-name", children: e.summary || "(no title)" }), _jsxs("span", { className: "tg-event-time", children: [hhmm(e.start), e.meet_link ? " · Meet" : ""] })] }, e.id));
|
|
104
|
+
})] }, ms));
|
|
105
|
+
})] })] }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, IconButton, Dialog, SegmentedControl } from "@rightkit/react-shell/react";
|
|
3
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "../icons.js";
|
|
5
|
+
import { MonthGrid, TimeGrid } from "./CalendarGrids.js";
|
|
6
|
+
import { DAY, rangeFor, startOfDay, startOfWeek } from "../lib/calendarUtils.js";
|
|
7
|
+
import { CalendarEventFormModal } from "./CalendarEventFormModal.js";
|
|
8
|
+
import { CalendarEventPopover } from "./CalendarEventPopover.js";
|
|
9
|
+
import { HostAccountSwitcher, useWorkspaceClient, useWorkspaceHost, useWorkspaceState } from "../host.js";
|
|
10
|
+
const CALENDAR_VIEWS = ["month", "week", "day"];
|
|
11
|
+
function CalendarViewInner() {
|
|
12
|
+
const [recurScope, setRecurScope] = useState(null);
|
|
13
|
+
const { listCalendars, listEvents, createEvent, updateEvent, deleteEvent, truncateEventSeries, rsvpEvent, freeBusy } = useWorkspaceClient();
|
|
14
|
+
const showDialog = useWorkspaceHost().dialog;
|
|
15
|
+
const setPref = useWorkspaceHost().savePreference;
|
|
16
|
+
const activeAccount = useWorkspaceState((s) => s.activeAccount);
|
|
17
|
+
const setErr = useWorkspaceHost().reportError;
|
|
18
|
+
const showUndo = useWorkspaceHost().notify;
|
|
19
|
+
const setCalendars = useWorkspaceState((s) => s.setCalendars);
|
|
20
|
+
const selectedCal = useWorkspaceState((s) => s.selectedCal);
|
|
21
|
+
const setSelectedCal = useWorkspaceState((s) => s.setSelectedCal);
|
|
22
|
+
const shownCals = useWorkspaceState((s) => s.shownCals);
|
|
23
|
+
const setShownCals = useWorkspaceState((s) => s.setShownCals);
|
|
24
|
+
const calView = useWorkspaceState((s) => s.calView);
|
|
25
|
+
const setCalView = useWorkspaceState((s) => s.setCalView);
|
|
26
|
+
const calCursor = useWorkspaceState((s) => s.calCursor);
|
|
27
|
+
const setCalCursor = useWorkspaceState((s) => s.setCalCursor);
|
|
28
|
+
const events = useWorkspaceState((s) => s.events);
|
|
29
|
+
const setEvents = useWorkspaceState((s) => s.setEvents);
|
|
30
|
+
const openEvent = useWorkspaceState((s) => s.openEvent);
|
|
31
|
+
const setOpenEvent = useWorkspaceState((s) => s.setOpenEvent);
|
|
32
|
+
const eventForm = useWorkspaceState((s) => s.eventForm);
|
|
33
|
+
const setEventForm = useWorkspaceState((s) => s.setEventForm);
|
|
34
|
+
const freeBusyMsg = useWorkspaceState((s) => s.freeBusyMsg);
|
|
35
|
+
const setFreeBusyMsg = useWorkspaceState((s) => s.setFreeBusyMsg);
|
|
36
|
+
const setCalendarBusy = useWorkspaceState((s) => s.setCalendarBusy);
|
|
37
|
+
const eventSeed = useWorkspaceState((s) => s.eventSeed);
|
|
38
|
+
const setEventSeed = useWorkspaceState((s) => s.setEventSeed);
|
|
39
|
+
const [calendarNow] = useState(() => {
|
|
40
|
+
const now = new Date();
|
|
41
|
+
return {
|
|
42
|
+
today: startOfDay(now.getTime()),
|
|
43
|
+
minute: now.getHours() * 60 + now.getMinutes(),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
async function reloadEvents() {
|
|
47
|
+
if (!activeAccount)
|
|
48
|
+
return;
|
|
49
|
+
const [minMs, maxMs] = rangeFor(calCursor, calView);
|
|
50
|
+
const min = new Date(minMs).toISOString();
|
|
51
|
+
const max = new Date(maxMs).toISOString();
|
|
52
|
+
const cals = Array.from(new Set(shownCals.length ? shownCals : [selectedCal]));
|
|
53
|
+
setCalendarBusy(true);
|
|
54
|
+
try {
|
|
55
|
+
const lists = await Promise.all(cals.map((c) => listEvents(activeAccount, c, min, max).catch(() => [])));
|
|
56
|
+
setEvents(lists.flat().sort((a, b) => {
|
|
57
|
+
const ax = a.start ? new Date(a.start).getTime() : 0;
|
|
58
|
+
const bx = b.start ? new Date(b.start).getTime() : 0;
|
|
59
|
+
return ax - bx;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
setErr(String(e));
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
setCalendarBusy(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function toLocalInput(s) {
|
|
70
|
+
try {
|
|
71
|
+
const d = new Date(s);
|
|
72
|
+
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function buildRrule(f) {
|
|
79
|
+
if (!f.recurrence)
|
|
80
|
+
return null;
|
|
81
|
+
const parts = [`FREQ=${f.recurrence}`];
|
|
82
|
+
if (f.recurrence === "WEEKLY" && f.recurByday.length > 0)
|
|
83
|
+
parts.push(`BYDAY=${f.recurByday.join(",")}`);
|
|
84
|
+
if (f.recurEnd === "until" && f.recurUntil) {
|
|
85
|
+
const ymd = f.recurUntil.replace(/-/g, "");
|
|
86
|
+
parts.push(f.allDay ? `UNTIL=${ymd}` : `UNTIL=${ymd}T235959Z`);
|
|
87
|
+
}
|
|
88
|
+
else if (f.recurEnd === "count") {
|
|
89
|
+
const n = Math.max(1, parseInt(f.recurCount, 10) || 0);
|
|
90
|
+
if (n > 0)
|
|
91
|
+
parts.push(`COUNT=${n}`);
|
|
92
|
+
}
|
|
93
|
+
return `RRULE:${parts.join(";")}`;
|
|
94
|
+
}
|
|
95
|
+
function fillEditForm(ev, targetId) {
|
|
96
|
+
setRecurScope(null);
|
|
97
|
+
setEventForm({
|
|
98
|
+
id: targetId,
|
|
99
|
+
summary: ev.summary,
|
|
100
|
+
start: ev.all_day ? `${ev.start.slice(0, 10)}T09:00` : toLocalInput(ev.start),
|
|
101
|
+
end: ev.all_day ? `${ev.end.slice(0, 10)}T10:00` : toLocalInput(ev.end),
|
|
102
|
+
allDay: ev.all_day,
|
|
103
|
+
attendees: ev.attendees.map((a) => a.email).join(", "),
|
|
104
|
+
reminderMinutes: "",
|
|
105
|
+
addMeet: !!ev.meet_link,
|
|
106
|
+
recurrence: "",
|
|
107
|
+
recurEnd: "never",
|
|
108
|
+
recurUntil: "",
|
|
109
|
+
recurCount: "",
|
|
110
|
+
recurByday: [],
|
|
111
|
+
});
|
|
112
|
+
setFreeBusyMsg("");
|
|
113
|
+
}
|
|
114
|
+
/** Open the new/edit form. `seedDay` (ms) pre-dates a new event from a grid click. */
|
|
115
|
+
function openEventForm(ev, prefillSummary, withMeet = false, seedDay) {
|
|
116
|
+
setOpenEvent(null);
|
|
117
|
+
if (ev) {
|
|
118
|
+
if (ev.is_recurring && ev.recurring_event_id) {
|
|
119
|
+
setRecurScope({ ev, mode: "edit" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
fillEditForm(ev, ev.id);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// seedDay is the exact start time for a grid-click (month cells pass 9am,
|
|
126
|
+
// the time grid passes the clicked hour); otherwise round to the next hour.
|
|
127
|
+
const base = seedDay !== undefined ? new Date(seedDay) : new Date();
|
|
128
|
+
if (seedDay === undefined)
|
|
129
|
+
base.setMinutes(0, 0, 0);
|
|
130
|
+
const start = new Date(base.getTime() - base.getTimezoneOffset() * 60000);
|
|
131
|
+
const end = new Date(start.getTime() + 3600000);
|
|
132
|
+
setEventForm({
|
|
133
|
+
summary: prefillSummary ?? "",
|
|
134
|
+
start: start.toISOString().slice(0, 16),
|
|
135
|
+
end: end.toISOString().slice(0, 16),
|
|
136
|
+
allDay: false,
|
|
137
|
+
attendees: "",
|
|
138
|
+
reminderMinutes: "10",
|
|
139
|
+
addMeet: withMeet,
|
|
140
|
+
recurrence: "",
|
|
141
|
+
recurEnd: "never",
|
|
142
|
+
recurUntil: "",
|
|
143
|
+
recurCount: "",
|
|
144
|
+
recurByday: [],
|
|
145
|
+
});
|
|
146
|
+
setFreeBusyMsg("");
|
|
147
|
+
}
|
|
148
|
+
async function checkAvailability() {
|
|
149
|
+
if (!activeAccount || !eventForm)
|
|
150
|
+
return;
|
|
151
|
+
const guests = eventForm.attendees.split(",").map((s) => s.trim()).filter(Boolean);
|
|
152
|
+
if (guests.length === 0) {
|
|
153
|
+
setFreeBusyMsg("Add guests first.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const startIso = eventForm.allDay ? `${eventForm.start.slice(0, 10)}T00:00:00Z` : new Date(eventForm.start).toISOString();
|
|
157
|
+
const endIso = eventForm.allDay ? `${eventForm.end.slice(0, 10)}T23:59:59Z` : new Date(eventForm.end || eventForm.start).toISOString();
|
|
158
|
+
setFreeBusyMsg("Checking…");
|
|
159
|
+
try {
|
|
160
|
+
const res = await freeBusy(activeAccount, guests, startIso, endIso);
|
|
161
|
+
const busy = res.filter((r) => r.busy.length > 0).map((r) => r.calendar_id);
|
|
162
|
+
setFreeBusyMsg(busy.length === 0 ? "All guests are free." : `Busy: ${busy.join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
setFreeBusyMsg(String(e));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function submitEvent() {
|
|
169
|
+
if (!activeAccount || !eventForm)
|
|
170
|
+
return;
|
|
171
|
+
if (!eventForm.summary.trim() || !eventForm.start) {
|
|
172
|
+
setErr("Event needs a title and start time.");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const f = eventForm;
|
|
176
|
+
const ev = {
|
|
177
|
+
summary: f.summary,
|
|
178
|
+
startRfc3339: f.allDay ? f.start.slice(0, 10) : new Date(f.start).toISOString(),
|
|
179
|
+
endRfc3339: f.allDay ? f.end.slice(0, 10) : new Date(f.end || f.start).toISOString(),
|
|
180
|
+
allDay: f.allDay,
|
|
181
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
182
|
+
attendees: f.attendees.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean),
|
|
183
|
+
reminderMinutes: f.reminderMinutes ? Number(f.reminderMinutes) : null,
|
|
184
|
+
addMeet: f.addMeet,
|
|
185
|
+
recurrence: buildRrule(f),
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
if (f.id)
|
|
189
|
+
await updateEvent(activeAccount, selectedCal, f.id, ev);
|
|
190
|
+
else
|
|
191
|
+
await createEvent(activeAccount, selectedCal, ev);
|
|
192
|
+
setEventForm(null);
|
|
193
|
+
await reloadEvents();
|
|
194
|
+
showUndo(f.id ? "Event updated" : "Event created");
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
setErr(String(e));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function performDelete(ev, targetId) {
|
|
201
|
+
if (!activeAccount)
|
|
202
|
+
return;
|
|
203
|
+
setRecurScope(null);
|
|
204
|
+
deleteEvent(activeAccount, ev.calendar_id || selectedCal, targetId)
|
|
205
|
+
.then(() => {
|
|
206
|
+
setOpenEvent(null);
|
|
207
|
+
return reloadEvents();
|
|
208
|
+
})
|
|
209
|
+
.then(() => showUndo("Event deleted"))
|
|
210
|
+
.catch((e) => setErr(String(e)));
|
|
211
|
+
}
|
|
212
|
+
function performTruncate(ev) {
|
|
213
|
+
if (!activeAccount)
|
|
214
|
+
return;
|
|
215
|
+
setRecurScope(null);
|
|
216
|
+
truncateEventSeries(activeAccount, ev.calendar_id || selectedCal, ev.recurring_event_id, ev.start)
|
|
217
|
+
.then(() => {
|
|
218
|
+
setOpenEvent(null);
|
|
219
|
+
return reloadEvents();
|
|
220
|
+
})
|
|
221
|
+
.then(() => showUndo("This and following events removed"))
|
|
222
|
+
.catch((e) => setErr(String(e)));
|
|
223
|
+
}
|
|
224
|
+
async function deleteEv(ev) {
|
|
225
|
+
if (!activeAccount)
|
|
226
|
+
return;
|
|
227
|
+
if (ev.is_recurring && ev.recurring_event_id) {
|
|
228
|
+
setRecurScope({ ev, mode: "delete" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if ((await showDialog({ kind: "confirm", message: `Delete "${ev.summary}"? Guests will be notified.` })) !== "ok")
|
|
232
|
+
return;
|
|
233
|
+
performDelete(ev, ev.id);
|
|
234
|
+
}
|
|
235
|
+
function rsvp(ev, response) {
|
|
236
|
+
if (!activeAccount)
|
|
237
|
+
return;
|
|
238
|
+
rsvpEvent(activeAccount, ev.calendar_id || selectedCal, ev.id, activeAccount, response)
|
|
239
|
+
.then(() => reloadEvents())
|
|
240
|
+
.then(() => {
|
|
241
|
+
setOpenEvent(null);
|
|
242
|
+
showUndo(`RSVP: ${response}`);
|
|
243
|
+
})
|
|
244
|
+
.catch((e) => setErr(String(e)));
|
|
245
|
+
}
|
|
246
|
+
// Load calendars once per account; pick the primary as the create target.
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
setEventForm(null);
|
|
249
|
+
setOpenEvent(null);
|
|
250
|
+
if (!activeAccount)
|
|
251
|
+
return;
|
|
252
|
+
setCalendarBusy(true);
|
|
253
|
+
listCalendars(activeAccount)
|
|
254
|
+
.then((cals) => {
|
|
255
|
+
setCalendars(cals);
|
|
256
|
+
const cal = cals.find((c) => c.primary)?.id || cals[0]?.id || "primary";
|
|
257
|
+
setSelectedCal(cal);
|
|
258
|
+
// Like Google web, show ALL calendars by default the first time (so the
|
|
259
|
+
// grid isn't blank when events live on secondary calendars). Only seed
|
|
260
|
+
// when the user hasn't already chosen a subset.
|
|
261
|
+
if (shownCals.length === 0 && cals.length > 0) {
|
|
262
|
+
const all = cals.map((c) => c.id);
|
|
263
|
+
setShownCals(all);
|
|
264
|
+
setPref("shownCals", all);
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.catch((e) => setErr(String(e)))
|
|
268
|
+
.finally(() => setCalendarBusy(false));
|
|
269
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setEventForm/setOpenEvent/setErr/shownCals.length/setShownCals are store setters or a derived read; tracking them would refetch calendars on every render instead of only on account change.
|
|
270
|
+
}, [activeAccount]);
|
|
271
|
+
// Re-fetch whenever the focused period, view, account, or visible calendars change.
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!activeAccount)
|
|
274
|
+
return;
|
|
275
|
+
reloadEvents();
|
|
276
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reloadEvents is a plain function redefined every render (closes over selectedCal/setErr); tracking it would refetch events on every render instead of only on the five real scope inputs.
|
|
277
|
+
}, [activeAccount, calCursor, calView, shownCals, selectedCal]);
|
|
278
|
+
// "Create event from email" / "Schedule meeting" → open the prefilled form.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (!eventSeed)
|
|
281
|
+
return;
|
|
282
|
+
const id = window.setTimeout(() => {
|
|
283
|
+
openEventForm(undefined, eventSeed.summary, eventSeed.withMeet);
|
|
284
|
+
setEventSeed(null);
|
|
285
|
+
}, 0);
|
|
286
|
+
return () => window.clearTimeout(id);
|
|
287
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- openEventForm/setEventSeed are recreated every render; tracking them would clear+rearm the timeout on every render instead of only when a new eventSeed arrives.
|
|
288
|
+
}, [eventSeed]);
|
|
289
|
+
function step(dir) {
|
|
290
|
+
const d = new Date(calCursor);
|
|
291
|
+
if (calView === "day")
|
|
292
|
+
setCalCursor(calCursor + dir * DAY);
|
|
293
|
+
else if (calView === "week")
|
|
294
|
+
setCalCursor(calCursor + dir * 7 * DAY);
|
|
295
|
+
else
|
|
296
|
+
setCalCursor(new Date(d.getFullYear(), d.getMonth() + dir, Math.min(d.getDate(), 28)).getTime());
|
|
297
|
+
}
|
|
298
|
+
// Wheel over the grid pages the period (Google-style), throttled so one
|
|
299
|
+
// gesture = one step rather than flying through months.
|
|
300
|
+
const wheelLock = useRef(0);
|
|
301
|
+
function onWheel(e) {
|
|
302
|
+
if (Math.abs(e.deltaY) < 4)
|
|
303
|
+
return;
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
if (now - wheelLock.current < 320)
|
|
306
|
+
return;
|
|
307
|
+
wheelLock.current = now;
|
|
308
|
+
step(e.deltaY > 0 ? 1 : -1);
|
|
309
|
+
}
|
|
310
|
+
function pickView(v) {
|
|
311
|
+
setCalView(v);
|
|
312
|
+
setPref("calView", v);
|
|
313
|
+
}
|
|
314
|
+
const headerLabel = (() => {
|
|
315
|
+
const d = new Date(calCursor);
|
|
316
|
+
if (calView === "day")
|
|
317
|
+
return d.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
|
318
|
+
if (calView === "week") {
|
|
319
|
+
const s = new Date(startOfWeek(calCursor));
|
|
320
|
+
const e = new Date(startOfWeek(calCursor) + 6 * DAY);
|
|
321
|
+
const sameMonth = s.getMonth() === e.getMonth();
|
|
322
|
+
return sameMonth
|
|
323
|
+
? `${s.toLocaleDateString([], { month: "long" })} ${s.getDate()}–${e.getDate()}, ${e.getFullYear()}`
|
|
324
|
+
: `${s.toLocaleDateString([], { month: "short", day: "numeric" })} – ${e.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}`;
|
|
325
|
+
}
|
|
326
|
+
return d.toLocaleDateString([], { month: "long", year: "numeric" });
|
|
327
|
+
})();
|
|
328
|
+
return (_jsxs("div", { className: "calview", children: [_jsxs("div", { className: "cal-bar", children: [_jsx(Button, { variant: "ghost", className: "cal-today", onClick: () => setCalCursor(Date.now()), children: "Today" }), _jsx(IconButton, { label: "Previous", className: "cal-step", title: "Previous", onClick: () => step(-1), children: _jsx(Icon, { name: "chevron-left", size: 18 }) }), _jsx(IconButton, { label: "Next", className: "cal-step", title: "Next", onClick: () => step(1), children: _jsx(Icon, { name: "chevron-right", size: 18 }) }), _jsx("h2", { className: "cal-label", children: headerLabel }), _jsx("div", { className: "cal-bar-spacer" }), _jsx("div", { className: "cal-viewtoggle", children: _jsx(SegmentedControl, { label: "Calendar view", value: calView, options: CALENDAR_VIEWS.map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) })), onChange: (v) => pickView(v) }) }), _jsx(HostAccountSwitcher, {})] }), _jsx("div", { className: "cal-grid", onWheel: onWheel, children: calView === "month" ? (_jsx(MonthGrid, { cursor: calCursor, events: events, today: calendarNow.today, onDay: (ms) => openEventForm(undefined, undefined, false, ms + 9 * 3600000), onEvent: setOpenEvent })) : (_jsx(TimeGrid, { days: calView === "week"
|
|
329
|
+
? Array.from({ length: 7 }, (_, i) => startOfWeek(calCursor) + i * DAY)
|
|
330
|
+
: [startOfDay(calCursor)], events: events, today: calendarNow.today, nowMin: calendarNow.minute, onSlot: (ms) => openEventForm(undefined, undefined, false, ms), onEvent: setOpenEvent })) }), openEvent && (_jsx(CalendarEventPopover, { openEvent: openEvent, setOpenEvent: setOpenEvent, openEventForm: openEventForm, deleteEv: deleteEv, rsvp: rsvp })), eventForm && (_jsx(CalendarEventFormModal, { eventForm: eventForm, setEventForm: setEventForm, freeBusyMsg: freeBusyMsg, checkAvailability: checkAvailability, submitEvent: submitEvent })), recurScope && (_jsxs(Dialog, { title: recurScope.mode === "edit" ? "Edit recurring event" : "Delete recurring event", width: 420, onClose: () => setRecurScope(null), children: [_jsxs("p", { className: "hint", children: ["\u201C", recurScope.ev.summary, "\u201D repeats. Apply to:"] }), _jsxs("div", { className: "recur-choices", children: [_jsx(Button, { variant: "primary", onClick: () => recurScope.mode === "edit" ? fillEditForm(recurScope.ev, recurScope.ev.id) : performDelete(recurScope.ev, recurScope.ev.id), children: "This event" }), recurScope.mode === "delete" && (_jsx(Button, { variant: "primary", onClick: () => performTruncate(recurScope.ev), children: "This and following events" })), _jsx(Button, { variant: "primary", onClick: () => recurScope.mode === "edit" ? fillEditForm(recurScope.ev, recurScope.ev.recurring_event_id) : performDelete(recurScope.ev, recurScope.ev.recurring_event_id), children: "All events" })] })] }))] }));
|
|
331
|
+
}
|
|
332
|
+
export const CalendarView = memo(CalendarViewInner);
|