@rapidmx/web-client 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/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { addMinutes, format, isSameDay, startOfDay } from "date-fns";
|
|
3
|
+
const HOUR_HEIGHT_PX = 48;
|
|
4
|
+
const SLOT_MINUTES = 30;
|
|
5
|
+
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
6
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
7
|
+
/**
|
|
8
|
+
* Outlook's "Split" view: one column per checked calendar, all showing the same single day, so
|
|
9
|
+
* events across calendars can be compared side by side. A deliberately separate component from
|
|
10
|
+
* `TimeGridView` rather than a generalization of it — it shares that component's visual language
|
|
11
|
+
* (same hour rail, same slot height) but is genuinely simpler in one respect: no drag-to-move/resize.
|
|
12
|
+
* Dragging an event between calendar columns would mean reassigning its `folderUid`, a real feature
|
|
13
|
+
* `moveOccurrence`/`resolveDragAction` don't support today; rather than bolt that on, this view is
|
|
14
|
+
* click-only (select an event, or click a slot to create one already targeted at that column's
|
|
15
|
+
* calendar) — a practical scope cut, not an oversight.
|
|
16
|
+
*/
|
|
17
|
+
export default function SplitDayView({ day, columns, occurrences, onSelectEvent, onSelectSlot }) {
|
|
18
|
+
const dayOccurrences = occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day));
|
|
19
|
+
const dayStart = startOfDay(day);
|
|
20
|
+
return (_jsxs("div", { role: "region", "aria-label": "Split view", className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), columns.map((col) => (_jsxs("div", { className: "flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border", children: [_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: col.color } }), _jsx("span", { className: "text-xs font-semibold truncate", children: col.name })] }, col.folderUid)))] }), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), columns.map((col) => (_jsx(SplitColumn, { column: col, dayStart: dayStart, occurrences: dayOccurrences.filter((occ) => occ.folderUid === col.folderUid), onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, col.folderUid)))] })] }));
|
|
21
|
+
}
|
|
22
|
+
function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlot, }) {
|
|
23
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 relative border-l border-border", children: [Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
24
|
+
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
25
|
+
return (_jsx("button", { type: "button", onClick: () => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid), style: { height: HOUR_HEIGHT_PX / 2 }, className: "block w-full border-b border-border/50 text-left", "aria-label": `New event at ${format(slotStart, "h:mm a")} in ${column.name}` }, i));
|
|
26
|
+
}), occurrences.map((occurrence) => {
|
|
27
|
+
const start = new Date(occurrence.startDate);
|
|
28
|
+
const end = new Date(occurrence.endDate);
|
|
29
|
+
const top = ((start.getTime() - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
|
|
30
|
+
const height = Math.max(((end.getTime() - start.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
31
|
+
const isFree = occurrence.busyStatus === "free";
|
|
32
|
+
return (_jsxs("div", { onClick: () => onSelectEvent(occurrence), style: {
|
|
33
|
+
position: "absolute",
|
|
34
|
+
top,
|
|
35
|
+
height,
|
|
36
|
+
left: 2,
|
|
37
|
+
right: 2,
|
|
38
|
+
...(isFree ? undefined : { backgroundColor: column.color, color: "#fff" }),
|
|
39
|
+
}, className: [
|
|
40
|
+
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
41
|
+
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
42
|
+
].join(" "), children: [_jsx("div", { className: "font-medium truncate", children: occurrence.title }), _jsx("div", { className: "truncate", children: format(start, "h:mma") })] }, occurrence.occurrenceKey));
|
|
43
|
+
})] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
3
|
+
import { addMinutes, format, isSameDay, isToday, startOfDay } from "date-fns";
|
|
4
|
+
import { eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
5
|
+
const HOUR_HEIGHT_PX = 48;
|
|
6
|
+
const SLOT_MINUTES = 30;
|
|
7
|
+
const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
8
|
+
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
9
|
+
/** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
|
|
10
|
+
* differ only in how many day columns are shown), with a day-header row and all-day events in a
|
|
11
|
+
* header strip above it. */
|
|
12
|
+
export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
|
|
13
|
+
const allDayEvents = occurrences.filter((occ) => occ.allDay);
|
|
14
|
+
const timedEvents = occurrences.filter((occ) => !occ.allDay);
|
|
15
|
+
return (_jsxs("div", { className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
|
|
16
|
+
.filter((occ) => isSameDay(new Date(occ.startDate), day))
|
|
17
|
+
.map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => isSameDay(new Date(occ.startDate), day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
|
|
18
|
+
}
|
|
19
|
+
function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
|
|
20
|
+
const dayStart = startOfDay(day);
|
|
21
|
+
return (_jsxs("div", { className: ["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" "), children: [Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
|
|
22
|
+
const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
|
|
23
|
+
return _jsx(TimeSlot, { start: slotStart, onSelectSlot: onSelectSlot }, i);
|
|
24
|
+
}), occurrences.map((occurrence) => (_jsx(EventBlock, { occurrence: occurrence, color: folderColors[occurrence.folderUid], dayStart: dayStart, onSelect: onSelectEvent }, occurrence.occurrenceKey)))] }));
|
|
25
|
+
}
|
|
26
|
+
function TimeSlot({ start, onSelectSlot }) {
|
|
27
|
+
const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
|
|
28
|
+
return (_jsx("button", { ref: setNodeRef, type: "button", onClick: () => onSelectSlot(start, addMinutes(start, SLOT_MINUTES)), style: { height: HOUR_HEIGHT_PX / 2 }, className: ["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" "), "aria-label": `New event at ${format(start, "h:mm a, MMM d")}` }));
|
|
29
|
+
}
|
|
30
|
+
function EventBlock({ occurrence, color, dayStart, onSelect, }) {
|
|
31
|
+
const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
|
|
32
|
+
const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
|
|
33
|
+
id: resizeDragId(occurrence),
|
|
34
|
+
});
|
|
35
|
+
const isFree = occurrence.busyStatus === "free";
|
|
36
|
+
const start = new Date(occurrence.startDate);
|
|
37
|
+
const end = new Date(occurrence.endDate);
|
|
38
|
+
const top = ((start.getTime() - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
|
|
39
|
+
// The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
|
|
40
|
+
// affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
|
|
41
|
+
// `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
|
|
42
|
+
// continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
|
|
43
|
+
const height = Math.max(((end.getTime() - start.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
|
|
44
|
+
return (_jsxs("div", { ref: setNodeRef, onClick: () => onSelect(occurrence), style: {
|
|
45
|
+
position: "absolute",
|
|
46
|
+
top,
|
|
47
|
+
height,
|
|
48
|
+
left: 2,
|
|
49
|
+
right: 2,
|
|
50
|
+
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
|
|
51
|
+
zIndex: isDragging ? 10 : undefined,
|
|
52
|
+
...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
|
|
53
|
+
}, className: [
|
|
54
|
+
"rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
|
|
55
|
+
isFree ? "bg-surface-alt text-text-muted" : "",
|
|
56
|
+
isDragging ? "opacity-50" : "",
|
|
57
|
+
].join(" "), ...listeners, ...attributes, children: [_jsx("div", { className: "font-medium truncate", children: occurrence.title }), _jsx("div", { className: "truncate", children: format(start, "h:mma") }), _jsx("div", { ref: setResizeRef, className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize", "aria-label": `Resize "${occurrence.title}"`, ...resizeListeners, ...resizeAttributes })] }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
|
+
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
const CalendarShellContext = createContext({ calendarFolders: [], mailboxes: [], reloadFolders: () => undefined });
|
|
16
|
+
/** Reads the mailbox/folder the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
|
|
17
|
+
export function useCalendarShell() {
|
|
18
|
+
return useContext(CalendarShellContext);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The Calendar app's shell — structurally identical to `ContactsShell`/`TasksShell` (a mailbox has
|
|
22
|
+
* exactly one well-known `calendar` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s
|
|
23
|
+
* eager provisioning in `@rapidmx/restapi`), just bound to a different folder type. Unlike Contacts/
|
|
24
|
+
* Tasks, the actual view being looked at (month/week/day, and which one) is *not* shell state — it
|
|
25
|
+
* lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between views/dates must
|
|
26
|
+
* be instant (no full page reload) and this framework has no client-side router to make a URL-driven
|
|
27
|
+
* approach for that free.
|
|
28
|
+
*/
|
|
29
|
+
export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
30
|
+
const [status, setStatus] = useState("checking");
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
33
|
+
const [folders, setFolders] = useState([]);
|
|
34
|
+
const [folderError, setFolderError] = useState(null);
|
|
35
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
36
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
|
|
39
|
+
}, []);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!userUid) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
listMailboxes({ limit: 100 })
|
|
45
|
+
.then((result) => {
|
|
46
|
+
setMailboxes(result);
|
|
47
|
+
setStatus("ready");
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
51
|
+
setStatus("error");
|
|
52
|
+
});
|
|
53
|
+
}, [userUid]);
|
|
54
|
+
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
55
|
+
mailboxes[0]?.uid;
|
|
56
|
+
const [folderRefreshToken, setFolderRefreshToken] = useState(0);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!mailboxUid) {
|
|
59
|
+
setFolders([]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setFolderError(null);
|
|
63
|
+
listFolders(mailboxUid)
|
|
64
|
+
.then(setFolders)
|
|
65
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder."));
|
|
66
|
+
}, [mailboxUid, folderRefreshToken]);
|
|
67
|
+
const calendarFolders = useMemo(() => folders.filter((f) => f.type === "calendar"), [folders]);
|
|
68
|
+
const folderUid = calendarFolders[0]?.uid;
|
|
69
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders: () => setFolderRefreshToken((t) => t + 1) }), [mailboxUid, folderUid, calendarFolders, mailboxes]);
|
|
70
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
71
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
|
|
72
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
73
|
+
return _jsx(MailboxProvisioning, {});
|
|
74
|
+
}
|
|
75
|
+
let inner = null;
|
|
76
|
+
if (userUid && status === "checking") {
|
|
77
|
+
// Renders immediately (no network round trip needed) so switching into Calendar never shows a
|
|
78
|
+
// blank pane while `listMailboxes()` is in flight — the actual sidebar (mini date-picker +
|
|
79
|
+
// calendar list) lives in `apps/www/calendar/index.tsx`'s own content, so this mimics its rough
|
|
80
|
+
// shape rather than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
|
|
81
|
+
inner = (_jsxs("div", { className: "w-56 shrink-0 bg-surface border-r border-border p-3 flex flex-col gap-4", children: [_jsx(Skeleton, { height: "h-40", className: "rounded-md" }), _jsx(SkeletonList, { count: 4 })] }));
|
|
82
|
+
}
|
|
83
|
+
else if (userUid && status === "error") {
|
|
84
|
+
inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
85
|
+
}
|
|
86
|
+
else if (userUid && status === "ready") {
|
|
87
|
+
// A function, not a plain JSX constant — see MailShell/ContactsShell's identical comment: it's
|
|
88
|
+
// rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
|
|
89
|
+
// `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
|
|
90
|
+
const mailboxSwitcher = (idPrefix) => mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-calendar-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-calendar-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
91
|
+
window.location.href = `/calendar?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
92
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] }));
|
|
93
|
+
inner = (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsx("div", { className: "hidden md:block w-56 shrink-0 bg-surface border-r border-border p-3", children: mailboxSwitcher("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsxs("div", { className: "flex flex-col gap-3", children: [mailboxSwitcher("mobile"), folderError && _jsx(Alert, { children: folderError })] }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), folderError && (_jsx("div", { className: "hidden md:block p-3", children: _jsx(Alert, { children: folderError }) })), _jsx(CalendarShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
94
|
+
}
|
|
95
|
+
return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
3
|
+
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
4
|
+
/**
|
|
5
|
+
* A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
|
|
6
|
+
* always visible alongside the contact list) and the mobile detail route
|
|
7
|
+
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
8
|
+
*/
|
|
9
|
+
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }) {
|
|
10
|
+
return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] }))] })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { createContact, updateContact, } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
|
+
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
13
|
+
const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
|
+
const ADDRESS_KINDS = ["home", "work", "other"];
|
|
15
|
+
/**
|
|
16
|
+
* A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
|
|
17
|
+
* mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
|
|
18
|
+
* desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
|
|
19
|
+
*/
|
|
20
|
+
export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }) {
|
|
21
|
+
const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
|
|
22
|
+
const [givenName, setGivenName] = useState(contact?.givenName ?? "");
|
|
23
|
+
const [surname, setSurname] = useState(contact?.surname ?? "");
|
|
24
|
+
const [company, setCompany] = useState(contact?.company ?? "");
|
|
25
|
+
const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
|
|
26
|
+
const [notes, setNotes] = useState(contact?.notes ?? "");
|
|
27
|
+
const [favorite, setFavorite] = useState(contact?.favorite ?? false);
|
|
28
|
+
const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
|
|
29
|
+
const [emails, setEmails] = useState(contact?.emails ?? []);
|
|
30
|
+
const [phones, setPhones] = useState(contact?.phones ?? []);
|
|
31
|
+
const [address, setAddress] = useState(contact?.addresses[0] ?? null);
|
|
32
|
+
const [error, setError] = useState(null);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
function updateEmail(index, patch) {
|
|
35
|
+
setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
|
|
36
|
+
}
|
|
37
|
+
function removeEmail(index) {
|
|
38
|
+
setEmails((prev) => prev.filter((_, i) => i !== index));
|
|
39
|
+
}
|
|
40
|
+
function updatePhone(index, patch) {
|
|
41
|
+
setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
|
42
|
+
}
|
|
43
|
+
function removePhone(index) {
|
|
44
|
+
setPhones((prev) => prev.filter((_, i) => i !== index));
|
|
45
|
+
}
|
|
46
|
+
async function handleSubmit(e) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
setError(null);
|
|
49
|
+
if (!displayName.trim()) {
|
|
50
|
+
setError("A display name is required.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setSaving(true);
|
|
54
|
+
try {
|
|
55
|
+
const categories = categoriesText
|
|
56
|
+
.split(",")
|
|
57
|
+
.map((c) => c.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
const input = {
|
|
60
|
+
displayName: displayName.trim(),
|
|
61
|
+
givenName: givenName.trim() || undefined,
|
|
62
|
+
surname: surname.trim() || undefined,
|
|
63
|
+
company: company.trim() || undefined,
|
|
64
|
+
jobTitle: jobTitle.trim() || undefined,
|
|
65
|
+
notes: notes.trim() || undefined,
|
|
66
|
+
favorite,
|
|
67
|
+
categories,
|
|
68
|
+
emails,
|
|
69
|
+
phones,
|
|
70
|
+
addresses: address ? [address] : [],
|
|
71
|
+
};
|
|
72
|
+
const saved = contact
|
|
73
|
+
? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
|
|
74
|
+
: await createContact({ mailboxUid: mailboxUid, folderUid: folderUid, ...input });
|
|
75
|
+
onSaved(saved);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
setSaving(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: address ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => setAddress({ ...address, street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => setAddress({ ...address, city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => setAddress({ ...address, state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => setAddress({ ...address, postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => setAddress({ ...address, country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => setAddress({ ...address, type: e.target.value }), "aria-label": "Address type", children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => setAddress(null), className: "self-start text-xs font-medium text-danger hover:underline", children: "Remove address" })] })) : (_jsx("button", { type: "button", onClick: () => setAddress({ type: "home" }), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
|
|
85
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import { createContactList, listContactLists } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
10
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
11
|
+
export function contactsViewKey(view) {
|
|
12
|
+
switch (view.type) {
|
|
13
|
+
case "list":
|
|
14
|
+
return `list:${view.uid}`;
|
|
15
|
+
case "category":
|
|
16
|
+
return `category:${view.name}`;
|
|
17
|
+
default:
|
|
18
|
+
return view.type;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A fixed palette of colors for category swatches, cycled by name — Outlook's own categories are
|
|
22
|
+
* user-assigned colors, but nothing server-side models a color for a category (it's a plain string label
|
|
23
|
+
* on `Contact.categories`), so this is a deterministic client-side stand-in, same idea as `ContactAvatar`'s
|
|
24
|
+
* per-name color. */
|
|
25
|
+
const CATEGORY_PALETTE = ["#dc2626", "#d97706", "#059669", "#2563eb", "#7c3aed", "#db2777"];
|
|
26
|
+
function categoryColor(name) {
|
|
27
|
+
let hash = 0;
|
|
28
|
+
for (let i = 0; i < name.length; i++) {
|
|
29
|
+
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
|
30
|
+
}
|
|
31
|
+
return CATEGORY_PALETTE[Math.abs(hash) % CATEGORY_PALETTE.length];
|
|
32
|
+
}
|
|
33
|
+
function NavItem({ label, count, active, onClick, swatch, }) {
|
|
34
|
+
return (_jsxs("button", { type: "button", onClick: onClick, "aria-current": active ? "true" : undefined, className: [
|
|
35
|
+
"w-full flex items-center justify-between gap-2 text-sm text-left px-2.5 py-1.5 rounded-sm",
|
|
36
|
+
active ? "bg-primary/10 text-primary-dark font-semibold" : "text-text hover:bg-surface-alt",
|
|
37
|
+
].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
|
|
41
|
+
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
42
|
+
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
43
|
+
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
44
|
+
* `ContactsShellContext` itself minimal (mailbox/folder resolution only).
|
|
45
|
+
*/
|
|
46
|
+
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken }) {
|
|
47
|
+
const [lists, setLists] = useState([]);
|
|
48
|
+
const [listsError, setListsError] = useState(null);
|
|
49
|
+
const [addingList, setAddingList] = useState(false);
|
|
50
|
+
const [newListName, setNewListName] = useState("");
|
|
51
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!mailboxUid) {
|
|
54
|
+
setLists([]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setListsError(null);
|
|
58
|
+
listContactLists(mailboxUid, { limit: 200 })
|
|
59
|
+
.then(setLists)
|
|
60
|
+
.catch((err) => setListsError(err instanceof ApiRequestError ? err.message : "Could not load contact lists."));
|
|
61
|
+
}, [mailboxUid, refreshToken]);
|
|
62
|
+
const favoriteCount = useMemo(() => contacts.filter((c) => c.favorite).length, [contacts]);
|
|
63
|
+
const listCounts = useMemo(() => {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const c of contacts) {
|
|
66
|
+
if (c.contactListUid) {
|
|
67
|
+
counts.set(c.contactListUid, (counts.get(c.contactListUid) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return counts;
|
|
71
|
+
}, [contacts]);
|
|
72
|
+
const categories = useMemo(() => {
|
|
73
|
+
const names = new Set();
|
|
74
|
+
for (const c of contacts) {
|
|
75
|
+
for (const category of c.categories ?? []) {
|
|
76
|
+
names.add(category);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
|
80
|
+
}, [contacts]);
|
|
81
|
+
async function handleAddList(e) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
if (!mailboxUid || !newListName.trim()) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const created = await createContactList({ mailboxUid, name: newListName.trim() });
|
|
88
|
+
setLists((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
|
|
89
|
+
setNewListName("");
|
|
90
|
+
setAddingList(false);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
setListsError(err instanceof ApiRequestError ? err.message : "Could not create this list.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function handleSelect(view) {
|
|
97
|
+
onSelect(view);
|
|
98
|
+
setDrawerOpen(false);
|
|
99
|
+
}
|
|
100
|
+
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
101
|
+
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
102
|
+
// behind the `idPrefix`).
|
|
103
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
104
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useRef } from "react";
|
|
7
|
+
import { HiOutlineArrowDownTray, HiOutlineArrowUpTray, HiOutlineEnvelope, HiOutlinePencil, HiOutlineStar, HiOutlineTag, HiOutlineTrash, HiOutlineUserPlus, HiStar, } from "react-icons/hi2";
|
|
8
|
+
function ToolbarButton({ label, icon: Icon, disabled, onClick, }) {
|
|
9
|
+
return (_jsxs("button", { type: "button", onClick: onClick, disabled: disabled, className: "flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt", children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
|
|
10
|
+
}
|
|
11
|
+
function Divider() {
|
|
12
|
+
return _jsx("div", { className: "w-px self-stretch my-1 bg-border", "aria-hidden": "true" });
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Contacts' Outlook-style ribbon toolbar. Deliberately presentational — every action is a callback the
|
|
16
|
+
* page (`apps/www/contacts/index.tsx`) implements, since it owns the actual contact data, the current
|
|
17
|
+
* selection, and the mailbox/folder context every real action (create/delete/email/favorite/category)
|
|
18
|
+
* needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
|
|
19
|
+
* "+ New list" affordance next to the "Your contact lists" heading.
|
|
20
|
+
*/
|
|
21
|
+
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, }) {
|
|
22
|
+
const importInputRef = useRef(null);
|
|
23
|
+
const hasSelection = selectedCount > 0;
|
|
24
|
+
function handleImportFileChosen(e) {
|
|
25
|
+
const file = e.target.files?.[0];
|
|
26
|
+
e.target.value = "";
|
|
27
|
+
if (file) {
|
|
28
|
+
onImportFile(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": "Contacts actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Edit", icon: HiOutlinePencil, disabled: selectedCount !== 1, onClick: onEdit }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Email", icon: HiOutlineEnvelope, disabled: !hasSelection, onClick: onEmail }), _jsx(ToolbarButton, { label: allSelectedFavorited ? "Unfavorite" : "Favorite", icon: allSelectedFavorited ? HiStar : HiOutlineStar, disabled: !hasSelection, onClick: onToggleFavorite }), _jsx(ToolbarButton, { label: "Add category", icon: HiOutlineTag, disabled: !hasSelection, onClick: onAddCategory }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Export", icon: HiOutlineArrowUpTray, disabled: !hasSelection, onClick: onExportVCard }), _jsx(ToolbarButton, { label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click() }), _jsx("input", { ref: importInputRef, type: "file", accept: ".vcf", className: "sr-only", "aria-label": "Import contacts file", onChange: handleImportFileChosen })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
|
+
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
const ContactsShellContext = createContext({ mailboxes: [] });
|
|
16
|
+
/** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
|
|
17
|
+
export function useContactsShell() {
|
|
18
|
+
return useContext(ContactsShellContext);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The Contacts app's shell: unlike `MailShell` (a folder tree, since a mailbox has many mail folders),
|
|
22
|
+
* a mailbox has exactly one well-known `contacts` folder — see `BaseMailboxRoute.create()`'s eager
|
|
23
|
+
* provisioning in `@rapidmx/restapi`, which guarantees it exists for every mailbox. This shell's own
|
|
24
|
+
* sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
|
|
25
|
+
* list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
|
|
26
|
+
*/
|
|
27
|
+
export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
28
|
+
const [status, setStatus] = useState("checking");
|
|
29
|
+
const [error, setError] = useState(null);
|
|
30
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
31
|
+
const [folders, setFolders] = useState([]);
|
|
32
|
+
const [folderError, setFolderError] = useState(null);
|
|
33
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
34
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
|
|
37
|
+
}, []);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!userUid) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
listMailboxes({ limit: 100 })
|
|
43
|
+
.then((result) => {
|
|
44
|
+
setMailboxes(result);
|
|
45
|
+
setStatus("ready");
|
|
46
|
+
})
|
|
47
|
+
.catch((err) => {
|
|
48
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
49
|
+
setStatus("error");
|
|
50
|
+
});
|
|
51
|
+
}, [userUid]);
|
|
52
|
+
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
53
|
+
mailboxes[0]?.uid;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!mailboxUid) {
|
|
56
|
+
setFolders([]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setFolderError(null);
|
|
60
|
+
listFolders(mailboxUid)
|
|
61
|
+
.then(setFolders)
|
|
62
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's contacts folder."));
|
|
63
|
+
}, [mailboxUid]);
|
|
64
|
+
const folderUid = folders.find((f) => f.type === "contacts")?.uid;
|
|
65
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, mailboxes }), [mailboxUid, folderUid, mailboxes]);
|
|
66
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
67
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
|
|
68
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
69
|
+
return _jsx(MailboxProvisioning, {});
|
|
70
|
+
}
|
|
71
|
+
let inner = null;
|
|
72
|
+
if (userUid && status === "checking") {
|
|
73
|
+
// Renders immediately (no network round trip needed) so switching into Contacts never shows a
|
|
74
|
+
// blank pane while `listMailboxes()` is in flight — the real sidebar (favorites/lists/categories)
|
|
75
|
+
// lives in `apps/www/contacts/index.tsx`'s own content, so this mimics its rough shape rather
|
|
76
|
+
// than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
|
|
77
|
+
inner = (_jsxs("div", { className: "w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3", children: [_jsx(Skeleton, { height: "h-9" }), _jsx(SkeletonList, { count: 5 })] }));
|
|
78
|
+
}
|
|
79
|
+
else if (userUid && status === "error") {
|
|
80
|
+
inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
81
|
+
}
|
|
82
|
+
else if (userUid && status === "ready") {
|
|
83
|
+
// A function, not a plain JSX constant — see MailShell's identical comment: it's rendered twice
|
|
84
|
+
// (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the `<select>`'s
|
|
85
|
+
// `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
|
|
86
|
+
// label-association break.
|
|
87
|
+
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-contacts-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-contacts-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
88
|
+
window.location.href = `/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
89
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
|
|
90
|
+
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(ContactsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
91
|
+
}
|
|
92
|
+
return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
|
|
93
|
+
}
|