@rapidmx/web-client 0.23.0 → 0.25.1
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/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +59 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +185 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +65 -2
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
|
|
|
12
12
|
import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
|
|
13
13
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
14
14
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
15
|
+
import { describeTimeZone } from "../../../lib/util/timeZone.js";
|
|
15
16
|
import Modal from "../../../lib/components/overlays/Modal.js";
|
|
16
17
|
|
|
17
18
|
function formatBytes(bytes: number): string {
|
|
@@ -156,7 +157,7 @@ function MailboxDetailContent({
|
|
|
156
157
|
<dt className="text-text-muted">Owner</dt>
|
|
157
158
|
<dd>{mailbox.ownerUserUid ?? "None (shared mailbox)"}</dd>
|
|
158
159
|
<dt className="text-text-muted">Timezone</dt>
|
|
159
|
-
<dd>{mailbox.timezone}</dd>
|
|
160
|
+
<dd>{describeTimeZone(mailbox.timezone)}</dd>
|
|
160
161
|
<dt className="text-text-muted">Quota</dt>
|
|
161
162
|
<dd>
|
|
162
163
|
{formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
HiOutlineUserGroup,
|
|
25
25
|
HiOutlineWrenchScrewdriver,
|
|
26
26
|
HiOutlineBars3,
|
|
27
|
+
HiOutlineChevronDoubleLeft,
|
|
28
|
+
HiOutlineChevronDoubleRight,
|
|
27
29
|
} from "react-icons/hi2";
|
|
28
30
|
import { apiFetch, ApiRequestError } from "../../../../../lib/util/api.js";
|
|
29
31
|
import { getSetupStatus } from "../../../../../lib/admin/setupApi.js";
|
|
@@ -35,6 +37,7 @@ import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
|
35
37
|
import type { NavItem } from "../../../../../lib/components/navigation/BottomTabBar.js";
|
|
36
38
|
import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
|
|
37
39
|
import RailIcon from "../../layout/RailIcon.js";
|
|
40
|
+
import { readRailExpanded, writeRailExpanded } from "./railPreference.js";
|
|
38
41
|
import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
|
|
39
42
|
import UserMenu from "../../layout/UserMenu.js";
|
|
40
43
|
import {
|
|
@@ -192,6 +195,15 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
192
195
|
const [status, setStatus] = useState<Status>("checking");
|
|
193
196
|
const [error, setError] = useState<string | null>(null);
|
|
194
197
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
198
|
+
// The side rail shows icons alone, or icons with their labels. Read after the first render: the page is drawn on the server first, which cannot know.
|
|
199
|
+
const [railExpanded, setRailExpanded] = useState(false);
|
|
200
|
+
useEffect(() => setRailExpanded(readRailExpanded()), []);
|
|
201
|
+
function toggleRail() {
|
|
202
|
+
setRailExpanded((expanded) => {
|
|
203
|
+
writeRailExpanded(!expanded);
|
|
204
|
+
return !expanded;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
195
207
|
// `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
|
|
196
208
|
// console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
|
|
197
209
|
const { branding, iconSrc } = useBranding();
|
|
@@ -309,7 +321,10 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
309
321
|
<div className="flex-1 flex min-h-0">
|
|
310
322
|
<nav
|
|
311
323
|
aria-label="Admin sections"
|
|
312
|
-
className=
|
|
324
|
+
className={[
|
|
325
|
+
"hidden md:flex shrink-0 bg-surface border-r border-border flex-col pb-3 gap-1",
|
|
326
|
+
railExpanded ? "w-56 items-stretch" : "w-16 items-center",
|
|
327
|
+
].join(" ")}
|
|
313
328
|
>
|
|
314
329
|
<RailIcon src={iconSrc} />
|
|
315
330
|
{navItems.map(({ id, href, label, icon: Icon }) => (
|
|
@@ -318,17 +333,37 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
318
333
|
href={href}
|
|
319
334
|
aria-label={label}
|
|
320
335
|
aria-current={id === active ? "page" : undefined}
|
|
321
|
-
title={label}
|
|
336
|
+
title={railExpanded ? undefined : label}
|
|
322
337
|
className={[
|
|
323
|
-
"
|
|
338
|
+
"h-10 flex items-center rounded-sm",
|
|
339
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
324
340
|
id === active
|
|
325
341
|
? "bg-primary/10 text-primary-dark"
|
|
326
342
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
327
343
|
].join(" ")}
|
|
328
344
|
>
|
|
329
|
-
<Icon size={20} aria-hidden="true" />
|
|
345
|
+
<Icon size={20} aria-hidden="true" className="shrink-0" />
|
|
346
|
+
{railExpanded && <span className="truncate">{label}</span>}
|
|
330
347
|
</a>
|
|
331
348
|
))}
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
aria-expanded={railExpanded}
|
|
352
|
+
aria-label={railExpanded ? "Collapse the menu" : "Expand the menu"}
|
|
353
|
+
title={railExpanded ? "Collapse the menu" : "Expand the menu"}
|
|
354
|
+
onClick={toggleRail}
|
|
355
|
+
className={[
|
|
356
|
+
"mt-auto h-10 flex items-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
|
|
357
|
+
railExpanded ? "gap-3 px-3 mx-2 text-sm font-medium" : "w-10 justify-center",
|
|
358
|
+
].join(" ")}
|
|
359
|
+
>
|
|
360
|
+
{railExpanded ? (
|
|
361
|
+
<HiOutlineChevronDoubleLeft size={20} aria-hidden="true" className="shrink-0" />
|
|
362
|
+
) : (
|
|
363
|
+
<HiOutlineChevronDoubleRight size={20} aria-hidden="true" className="shrink-0" />
|
|
364
|
+
)}
|
|
365
|
+
{railExpanded && <span>Collapse</span>}
|
|
366
|
+
</button>
|
|
332
367
|
</nav>
|
|
333
368
|
{/* Below `md` the sections are a menu that slides in from the left: ten or so of them don't fit a bar along the bottom. */}
|
|
334
369
|
<Drawer open={menuOpen} onClose={() => setMenuOpen(false)} title="Admin" fullScreen>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** Where this browser remembers whether the admin console's side rail is showing its labels. */
|
|
7
|
+
export const RAIL_EXPANDED_KEY = "rapidmx:admin-rail-expanded";
|
|
8
|
+
|
|
9
|
+
/** Whether the rail was left expanded. A missing or blocked store means collapsed, as it always was. */
|
|
10
|
+
export function readRailExpanded(): boolean {
|
|
11
|
+
try {
|
|
12
|
+
return localStorage.getItem(RAIL_EXPANDED_KEY) === "true";
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Remembers the rail's state. Best effort: a blocked store only means it starts collapsed the next time. */
|
|
19
|
+
export function writeRailExpanded(expanded: boolean): void {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(RAIL_EXPANDED_KEY, String(expanded));
|
|
22
|
+
} catch {
|
|
23
|
+
// Nothing to do.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -7,7 +7,7 @@ import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
|
7
7
|
import { getMailboxPolicy } from "../../../../../lib/admin/mailboxPolicyApi.js";
|
|
8
8
|
import { LeftoverConflict, leftoverConflictOf } from "../../../../../lib/admin/leftoverMailboxApi.js";
|
|
9
9
|
import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "../../../../../lib/mail/mailApi.js";
|
|
10
|
-
import { deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
10
|
+
import { describeTimeZone, deviceTimeZone, zoneClock } from "../../../../../lib/util/timeZone.js";
|
|
11
11
|
import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
|
|
12
12
|
import Alert from "../../../../../lib/components/feedback/Alert.js";
|
|
13
13
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
@@ -298,6 +298,7 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
298
298
|
onChange={(e) => setTimezone(e.target.value)}
|
|
299
299
|
placeholder="America/Los_Angeles"
|
|
300
300
|
/>
|
|
301
|
+
{zoneClock(timezone) && <p className="text-xs text-text-muted mt-1">{describeTimeZone(timezone)}</p>}
|
|
301
302
|
</FormField>
|
|
302
303
|
|
|
303
304
|
<FormField label="Quota (GB)" htmlFor="quotaGb">
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { EventFormController } from "./eventForm.js";
|
|
20
20
|
import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
|
|
21
21
|
import QuickFaceFrame from "./QuickFaceFrame.js";
|
|
22
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
22
23
|
|
|
23
24
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
24
25
|
const DURATIONS = ["15", "30", "45", "60"];
|
|
@@ -298,7 +299,7 @@ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, se
|
|
|
298
299
|
onChange={(e) => onDraftChange({ endTime: e.target.value })}
|
|
299
300
|
/>
|
|
300
301
|
</div>
|
|
301
|
-
<p className="text-xs text-text-muted mt-1">Times are in {timeZone
|
|
302
|
+
<p className="text-xs text-text-muted mt-1">Times are in {describeTimeZone(timeZone)}.</p>
|
|
302
303
|
</IconRow>
|
|
303
304
|
|
|
304
305
|
<IconRow icon={<HiOutlineMapPin size={20} />}>
|
|
@@ -7,6 +7,7 @@ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from ".
|
|
|
7
7
|
import { Folder, Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
8
8
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
|
+
import { SharedMailboxMark } from "../mail/layout/folderIcons.js";
|
|
10
11
|
|
|
11
12
|
export interface CalendarListSidebarMailbox {
|
|
12
13
|
mailbox: Mailbox;
|
|
@@ -108,7 +109,7 @@ export default function CalendarListSidebar({
|
|
|
108
109
|
return (
|
|
109
110
|
<nav aria-label="My calendars" className="p-3 border-t border-border flex flex-col gap-3">
|
|
110
111
|
{mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
|
|
111
|
-
const title = multiple ?
|
|
112
|
+
const title = multiple ? mailbox.displayName : "My calendars";
|
|
112
113
|
return (
|
|
113
114
|
<div key={mailbox.uid}>
|
|
114
115
|
<div className="flex items-center justify-between mb-1 gap-2">
|
|
@@ -120,7 +121,10 @@ export default function CalendarListSidebar({
|
|
|
120
121
|
style={{ backgroundColor: accentColorForMailbox(mailbox.uid) }}
|
|
121
122
|
/>
|
|
122
123
|
)}
|
|
123
|
-
<span className="truncate">
|
|
124
|
+
<span className="truncate">
|
|
125
|
+
{title}
|
|
126
|
+
{multiple && !mailbox.ownerUserUid && <SharedMailboxMark />}
|
|
127
|
+
</span>
|
|
124
128
|
</h2>
|
|
125
129
|
<button
|
|
126
130
|
type="button"
|
|
@@ -5,18 +5,50 @@
|
|
|
5
5
|
import React, { ReactNode } from "react";
|
|
6
6
|
import { DescriptionNode, htmlToPlainText, parseEventDescription } from "../../../../lib/calendar/eventDescription.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const URL_PATTERN = /https?:\/\/[^\s<>"']+/gi;
|
|
9
|
+
const LINK_CLASS = "text-primary-dark underline break-words";
|
|
10
|
+
|
|
11
|
+
function count(text: string, character: string): number {
|
|
12
|
+
return text.split(character).length - 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
|
|
17
|
+
* and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
|
|
18
|
+
*/
|
|
19
|
+
export function linkifyText(text: string): ReactNode[] {
|
|
20
|
+
const parts: ReactNode[] = [];
|
|
21
|
+
let last = 0;
|
|
22
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
23
|
+
let url = match[0];
|
|
24
|
+
while (/[.,;:!?]$/.test(url) || (url.endsWith(")") && count(url, ")") > count(url, "(")) || (url.endsWith("]") && count(url, "]") > count(url, "["))) {
|
|
25
|
+
url = url.slice(0, -1);
|
|
26
|
+
}
|
|
27
|
+
const at = match.index;
|
|
28
|
+
parts.push(text.slice(last, at));
|
|
29
|
+
parts.push(
|
|
30
|
+
<a key={at} href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
|
31
|
+
{url}
|
|
32
|
+
</a>,
|
|
33
|
+
);
|
|
34
|
+
last = at + url.length;
|
|
35
|
+
}
|
|
36
|
+
parts.push(text.slice(last));
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderNodes(nodes: DescriptionNode[], insideLink = false): ReactNode[] {
|
|
9
41
|
return nodes.map((node, index) => {
|
|
10
42
|
if ("text" in node) {
|
|
11
|
-
return node.text
|
|
43
|
+
return insideLink ? node.text : <React.Fragment key={index}>{linkifyText(node.text)}</React.Fragment>;
|
|
12
44
|
}
|
|
13
45
|
switch (node.tag) {
|
|
14
46
|
case "br":
|
|
15
47
|
return <br key={index} />;
|
|
16
48
|
case "a":
|
|
17
49
|
return (
|
|
18
|
-
<a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className=
|
|
19
|
-
{renderNodes(node.children)}
|
|
50
|
+
<a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
|
51
|
+
{renderNodes(node.children, true)}
|
|
20
52
|
</a>
|
|
21
53
|
);
|
|
22
54
|
case "ul":
|
|
@@ -66,5 +98,5 @@ export default function EventDescriptionView({ html, text, className }: EventDes
|
|
|
66
98
|
if (htmlToPlainText(html) !== "") {
|
|
67
99
|
return <div className={["break-words", className].filter(Boolean).join(" ")}>{renderNodes(parseEventDescription(html))}</div>;
|
|
68
100
|
}
|
|
69
|
-
return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{text}</div> : null;
|
|
101
|
+
return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{linkifyText(text)}</div> : null;
|
|
70
102
|
}
|
|
@@ -22,13 +22,13 @@ import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
|
|
|
22
22
|
import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
|
|
23
23
|
import { CalendarOccurrence, describeRecurrence } from "../../../../lib/calendar/recurrence.js";
|
|
24
24
|
import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
|
|
25
|
-
import { deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
25
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
26
26
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
27
27
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
28
28
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
29
29
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
30
30
|
import RequestChangeForm from "./RequestChangeForm.js";
|
|
31
|
-
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
|
|
31
|
+
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
|
|
32
32
|
|
|
33
33
|
const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" } as const;
|
|
34
34
|
|
|
@@ -220,11 +220,20 @@ export default function EventDetails({
|
|
|
220
220
|
{error && <Alert>{error}</Alert>}
|
|
221
221
|
|
|
222
222
|
<DetailRow icon={<HiOutlineClock size={18} />}>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
{!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (
|
|
224
|
+
// Set somewhere else: the time on the reader's own clock, and on the clock it was set on, each said in full so nobody has to convert.
|
|
225
|
+
<>
|
|
226
|
+
<div>
|
|
227
|
+
{formatStoredWhen(occurrence.startDate, occurrence.endDate, false)} • {describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="text-text-muted">
|
|
230
|
+
{formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone)} • {describeTimeZone(eventZone, new Date(occurrence.startDate))}
|
|
231
|
+
</div>
|
|
232
|
+
</>
|
|
233
|
+
) : (
|
|
234
|
+
<div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
|
|
227
235
|
)}
|
|
236
|
+
{occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
|
|
228
237
|
</DetailRow>
|
|
229
238
|
|
|
230
239
|
{occurrence.location && (
|
|
@@ -34,6 +34,7 @@ import QuickCreateFaces, { QuickCreateConfig } from "./QuickCreateFaces.js";
|
|
|
34
34
|
import { EditScope, EventFormController, EventFormValues } from "./eventForm.js";
|
|
35
35
|
import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
36
36
|
import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
37
|
+
import { nextHalfHour } from "./timePicker.js";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
@@ -190,8 +191,10 @@ export default function EventEditor({
|
|
|
190
191
|
start = `${format(initialStart ?? new Date(), "yyyy-MM-dd")}T00:00`;
|
|
191
192
|
end = start;
|
|
192
193
|
} else {
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
// With nothing clicked, a new event starts at the next half hour and lasts an hour.
|
|
195
|
+
const newStart = initialStart ?? nextHalfHour(new Date());
|
|
196
|
+
start = toDatetimeLocal(occurrence?.startDate ?? newStart.toISOString());
|
|
197
|
+
end = toDatetimeLocal(occurrence?.endDate ?? (initialEnd ?? new Date(newStart.getTime() + MS_PER_HOUR)).toISOString());
|
|
195
198
|
}
|
|
196
199
|
return {
|
|
197
200
|
title: occurrence?.title ?? "",
|
|
@@ -9,9 +9,12 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
9
9
|
import GuestInput from "./GuestInput.js";
|
|
10
10
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
11
11
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
12
|
+
import TimeComboBox from "./TimeComboBox.js";
|
|
13
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
12
14
|
import ResourcePicker from "./ResourcePicker.js";
|
|
13
15
|
import { EventFormController } from "./eventForm.js";
|
|
14
16
|
import { hasDescriptionText } from "./eventDialogFields.js";
|
|
17
|
+
import { dayTimes, endTimeOptions, formatTimeOfDay } from "./timePicker.js";
|
|
15
18
|
import {
|
|
16
19
|
REMINDER_UNITS,
|
|
17
20
|
ReminderUnit,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
VISIBILITY_HELP,
|
|
21
24
|
VISIBILITY_LABEL,
|
|
22
25
|
bestReminderUnit,
|
|
26
|
+
zoneDate,
|
|
23
27
|
} from "./eventFormat.js";
|
|
24
28
|
|
|
25
29
|
export const INPUT_CLASS =
|
|
@@ -378,7 +382,9 @@ export function RecurrenceSelect({
|
|
|
378
382
|
);
|
|
379
383
|
}
|
|
380
384
|
|
|
381
|
-
|
|
385
|
+
const START_TIME_OPTIONS = dayTimes().map((time) => ({ value: time, label: formatTimeOfDay(time) }));
|
|
386
|
+
|
|
387
|
+
/** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
|
|
382
388
|
export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
383
389
|
const { values } = c;
|
|
384
390
|
const [zoneOpen, setZoneOpen] = useState(false);
|
|
@@ -393,22 +399,27 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
|
393
399
|
onChange={(e) => c.setStart(`${e.target.value}${values.start.slice(10)}`)}
|
|
394
400
|
/>
|
|
395
401
|
{!values.allDay && (
|
|
396
|
-
<
|
|
397
|
-
type="time"
|
|
402
|
+
<TimeComboBox
|
|
398
403
|
aria-label="Event start time"
|
|
399
|
-
className={`${SELECT_CLASS}
|
|
400
|
-
|
|
401
|
-
|
|
404
|
+
className={`${SELECT_CLASS} w-28`}
|
|
405
|
+
display={formatTimeOfDay(values.start.slice(11, 16))}
|
|
406
|
+
current={values.start.slice(11, 16)}
|
|
407
|
+
options={START_TIME_OPTIONS}
|
|
408
|
+
// Moving the start moves the end with it, so the event keeps its length: whatever the start is set to.
|
|
409
|
+
onSelect={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
|
|
410
|
+
onType={(time) => c.setStart(`${values.start.slice(0, 11)}${time}`)}
|
|
402
411
|
/>
|
|
403
412
|
)}
|
|
404
413
|
<span className="text-text-muted">to</span>
|
|
405
414
|
{!values.allDay && (
|
|
406
|
-
<
|
|
407
|
-
type="time"
|
|
415
|
+
<TimeComboBox
|
|
408
416
|
aria-label="Event end time"
|
|
409
|
-
className={`${SELECT_CLASS}
|
|
410
|
-
|
|
411
|
-
|
|
417
|
+
className={`${SELECT_CLASS} w-28`}
|
|
418
|
+
display={formatTimeOfDay(values.end.slice(11, 16))}
|
|
419
|
+
current={values.end}
|
|
420
|
+
options={endTimeOptions(values.start).map((option) => ({ value: option.value, label: option.label, hint: option.hint }))}
|
|
421
|
+
onSelect={c.setEnd}
|
|
422
|
+
onType={(time) => c.setEnd(`${values.end.slice(0, 11)}${time}`)}
|
|
412
423
|
/>
|
|
413
424
|
)}
|
|
414
425
|
<input
|
|
@@ -419,36 +430,31 @@ export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
|
419
430
|
onChange={(e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`)}
|
|
420
431
|
/>
|
|
421
432
|
</div>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
onClick={() => setZoneOpen((open) => !open)}
|
|
432
|
-
>
|
|
433
|
-
Time zone
|
|
434
|
-
</button>
|
|
435
|
-
{!c.editingSingleOccurrence && (
|
|
436
|
-
<RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
|
|
437
|
-
)}
|
|
438
|
-
</div>
|
|
433
|
+
{/* The Time zone link sits above All day, and the zone menu it opens right under it - before how the event repeats. */}
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
aria-expanded={zoneOpen}
|
|
437
|
+
className="self-start text-sm text-primary-dark hover:underline"
|
|
438
|
+
onClick={() => setZoneOpen((open) => !open)}
|
|
439
|
+
>
|
|
440
|
+
Time zone
|
|
441
|
+
</button>
|
|
439
442
|
{zoneOpen && (
|
|
440
|
-
<
|
|
441
|
-
aria-label="
|
|
442
|
-
className={`${SELECT_CLASS}
|
|
443
|
+
<TimeZonePicker
|
|
444
|
+
aria-label="Event time zone"
|
|
445
|
+
className={`${SELECT_CLASS} max-w-full`}
|
|
443
446
|
value={values.formZone}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
{c.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
447
|
+
zones={c.timeZones}
|
|
448
|
+
at={zoneDate(values.start)}
|
|
449
|
+
onChange={c.onTimeZoneChange}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
<label className="flex items-center gap-2 text-sm">
|
|
453
|
+
<input type="checkbox" checked={values.allDay} onChange={(e) => c.onAllDayChange(e.target.checked)} />
|
|
454
|
+
All day
|
|
455
|
+
</label>
|
|
456
|
+
{!c.editingSingleOccurrence && (
|
|
457
|
+
<RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
|
|
452
458
|
)}
|
|
453
459
|
</div>
|
|
454
460
|
);
|
|
@@ -9,8 +9,9 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
|
9
9
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
10
10
|
import { EventShellContext } from "./EventShell.js";
|
|
11
11
|
import { CalendarField, DateTimeControls, DescriptionRow, GuestsRow, IconRow, LocationRow, VideoConferencingRow } from "./EventFormParts.js";
|
|
12
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
12
13
|
import { EventFormController } from "./eventForm.js";
|
|
13
|
-
import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf } from "./eventFormat.js";
|
|
14
|
+
import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf, zoneDate } from "./eventFormat.js";
|
|
14
15
|
|
|
15
16
|
export interface EventQuickFormProps {
|
|
16
17
|
c: EventFormController;
|
|
@@ -79,7 +80,7 @@ export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }: Eve
|
|
|
79
80
|
>
|
|
80
81
|
<span className="block text-sm">{formatFormWhen(values.start, values.end, values.allDay)}</span>
|
|
81
82
|
<span className="block text-xs text-text-muted">
|
|
82
|
-
{values.formZone
|
|
83
|
+
{describeTimeZone(values.formZone, zoneDate(values.start))} •{" "}
|
|
83
84
|
{values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"}
|
|
84
85
|
</span>
|
|
85
86
|
</button>
|
|
@@ -19,7 +19,8 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
19
19
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
20
20
|
import { addDaysToKey } from "./allDay.js";
|
|
21
21
|
import { EventFormController } from "./eventForm.js";
|
|
22
|
-
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
22
|
+
import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs, zoneDate } from "./eventFormat.js";
|
|
23
|
+
import { describeTimeZone } from "../../../../lib/util/timeZone.js";
|
|
23
24
|
import {
|
|
24
25
|
SEARCH_DAYS,
|
|
25
26
|
WORK_END_HOUR,
|
|
@@ -238,7 +239,7 @@ export default function FindATime({ c }: { c: EventFormController }) {
|
|
|
238
239
|
>
|
|
239
240
|
›
|
|
240
241
|
</button>
|
|
241
|
-
<span className="text-xs text-text-muted">{formZone
|
|
242
|
+
<span className="text-xs text-text-muted">{describeTimeZone(formZone, zoneDate(dayKey))}</span>
|
|
242
243
|
</div>
|
|
243
244
|
|
|
244
245
|
{lookUp.status === "error" && (
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
6
|
+
import { parseTimeInput } from "./timePicker.js";
|
|
7
|
+
|
|
8
|
+
export interface TimeOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Said after the label, muted: the length of the event an end time makes. */
|
|
12
|
+
hint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimeComboBoxProps {
|
|
16
|
+
"aria-label": string;
|
|
17
|
+
/** The time being shown, as it reads in the field. */
|
|
18
|
+
display: string;
|
|
19
|
+
/** The `value` of the option that is the current one, if any of them is. */
|
|
20
|
+
current?: string;
|
|
21
|
+
options: TimeOption[];
|
|
22
|
+
/** An option was picked. */
|
|
23
|
+
onSelect: (value: string) => void;
|
|
24
|
+
/** A time was typed (`HH:mm`, already understood). */
|
|
25
|
+
onType: (time: string) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
|
|
31
|
+
* is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
|
|
32
|
+
*/
|
|
33
|
+
export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }: TimeComboBoxProps) {
|
|
34
|
+
const listId = useId();
|
|
35
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [active, setActive] = useState(-1);
|
|
38
|
+
const selectedRef = useRef<HTMLLIElement>(null);
|
|
39
|
+
|
|
40
|
+
// Opening puts the current time in view, as the middle of the list.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (open) {
|
|
43
|
+
selectedRef.current?.scrollIntoView?.({ block: "center" });
|
|
44
|
+
}
|
|
45
|
+
}, [open]);
|
|
46
|
+
|
|
47
|
+
function close() {
|
|
48
|
+
setOpen(false);
|
|
49
|
+
setActive(-1);
|
|
50
|
+
setDraft(null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function commitDraft() {
|
|
54
|
+
const time = draft === null ? null : parseTimeInput(draft);
|
|
55
|
+
if (time !== null) {
|
|
56
|
+
onType(time);
|
|
57
|
+
}
|
|
58
|
+
close();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pick(value: string) {
|
|
62
|
+
onSelect(value);
|
|
63
|
+
close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
67
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
setOpen(true);
|
|
70
|
+
setActive((index) => {
|
|
71
|
+
const from = index < 0 ? options.findIndex((option) => option.value === current) : index;
|
|
72
|
+
return Math.min(Math.max(from + (event.key === "ArrowDown" ? 1 : -1), 0), options.length - 1);
|
|
73
|
+
});
|
|
74
|
+
} else if (event.key === "Enter") {
|
|
75
|
+
// Never the form's own submit: Enter here settles the time.
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
if (open && active >= 0) {
|
|
78
|
+
pick(options[active].value);
|
|
79
|
+
} else {
|
|
80
|
+
commitDraft();
|
|
81
|
+
}
|
|
82
|
+
} else if (event.key === "Escape" && open) {
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="relative">
|
|
90
|
+
<input
|
|
91
|
+
type="text"
|
|
92
|
+
role="combobox"
|
|
93
|
+
aria-label={label}
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
aria-controls={listId}
|
|
96
|
+
aria-autocomplete="list"
|
|
97
|
+
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
|
|
98
|
+
autoComplete="off"
|
|
99
|
+
className={className}
|
|
100
|
+
value={draft ?? display}
|
|
101
|
+
onClick={() => setOpen(true)}
|
|
102
|
+
onFocus={(event) => {
|
|
103
|
+
event.currentTarget.select();
|
|
104
|
+
setOpen(true);
|
|
105
|
+
}}
|
|
106
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
107
|
+
onBlur={commitDraft}
|
|
108
|
+
onKeyDown={handleKeyDown}
|
|
109
|
+
/>
|
|
110
|
+
{open && (
|
|
111
|
+
<ul
|
|
112
|
+
id={listId}
|
|
113
|
+
role="listbox"
|
|
114
|
+
aria-label={`${label} options`}
|
|
115
|
+
className="absolute left-0 top-full z-30 mt-1 max-h-56 w-44 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
|
|
116
|
+
>
|
|
117
|
+
{options.map((option, index) => (
|
|
118
|
+
<li
|
|
119
|
+
key={option.value}
|
|
120
|
+
id={`${listId}-${index}`}
|
|
121
|
+
ref={option.value === current ? selectedRef : undefined}
|
|
122
|
+
role="option"
|
|
123
|
+
aria-selected={option.value === current}
|
|
124
|
+
// Before the field's own blur, which would otherwise close the list and swallow the click.
|
|
125
|
+
onMouseDown={(event) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
pick(option.value);
|
|
128
|
+
}}
|
|
129
|
+
className={[
|
|
130
|
+
"flex cursor-pointer items-baseline gap-1.5 px-3 py-1.5 text-sm hover:bg-surface-alt",
|
|
131
|
+
option.value === current ? "font-semibold text-primary-dark" : "",
|
|
132
|
+
index === active ? "bg-surface-alt" : "",
|
|
133
|
+
].join(" ")}
|
|
134
|
+
>
|
|
135
|
+
{option.label}
|
|
136
|
+
{option.hint && <span className="text-xs font-normal text-text-muted">{option.hint}</span>}
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|