@rapidmx/web-client 0.28.0 → 0.29.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.
Files changed (119) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +29 -3
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/www/calendar/index.tsx +251 -53
  37. package/apps/www/index.tsx +522 -343
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  40. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  42. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  44. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  46. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  48. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  50. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  51. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  52. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  53. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  54. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  55. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  56. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  57. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  58. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  59. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  60. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  61. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  62. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  63. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  64. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  65. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  66. package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
  67. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  68. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  69. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  70. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  71. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  73. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  75. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  77. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  78. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  79. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  80. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  81. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  83. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  84. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  86. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  88. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  89. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  90. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  91. package/dist/apps/shared/keyboard/keymap.js +2 -0
  92. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  93. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  94. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  95. package/dist/apps/shared/mail/rowExit.js +33 -0
  96. package/dist/apps/www/calendar/index.js +177 -18
  97. package/dist/apps/www/index.js +220 -65
  98. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  99. package/dist/lib/calendar/eventSearch.js +152 -0
  100. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  101. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  102. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  103. package/dist/lib/components/navigation/NavBadge.js +18 -0
  104. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  105. package/dist/lib/mail/compose/quotedImages.js +41 -0
  106. package/dist/lib/mail/inlineImages.d.ts +38 -0
  107. package/dist/lib/mail/inlineImages.js +90 -0
  108. package/dist/lib/mail/mailApi.d.ts +8 -1
  109. package/dist/lib/mail/mailApi.js +57 -5
  110. package/lib/calendar/eventSearch.ts +172 -0
  111. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  112. package/lib/components/navigation/NavBadge.tsx +33 -0
  113. package/lib/mail/compose/quotedImages.ts +49 -0
  114. package/lib/mail/inlineImages.ts +104 -0
  115. package/lib/mail/mailApi.ts +64 -5
  116. package/package.json +1 -1
  117. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  118. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  119. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { SaveFile } from "./download.js";
3
3
  import type { LogSocketFactory } from "./logClient.js";
4
- export type DiagnosticsTab = "versions" | "runtime" | "system" | "logs";
4
+ export type DiagnosticsTab = "information" | "runtime" | "system" | "logs";
5
5
  export interface DiagnosticsManagerProps {
6
6
  /** Creates the log stream's WebSocket. Defaults to the browser's. For a test. */
7
7
  createLogSocket?: LogSocketFactory;
@@ -9,8 +9,8 @@ export interface DiagnosticsManagerProps {
9
9
  saveFile?: SaveFile;
10
10
  }
11
11
  /**
12
- * The Diagnostics page: what is installed (Versions), what it runs on (Runtime), how it is doing right now (System, live) and what
13
- * the server is logging (Logs, live, with captures). Versions and Runtime are read when the page opens and again on Refresh, and
12
+ * The Diagnostics page: what is installed and how it is set up (Information), what it runs on (Runtime), how it is doing right now (System, live) and what
13
+ * the server is logging (Logs, live, with captures). Information and Runtime are read when the page opens and again on Refresh, and
14
14
  * are not polled. The live tabs only work while they are open: System polls only on its tab, and the log stream is opened the
15
15
  * first time the Logs tab is (and closes with the page, or on Stop).
16
16
  */
@@ -6,9 +6,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import { HiOutlineArrowDownTray, HiOutlineArrowPath } from "react-icons/hi2";
8
8
  import Button from "../../../../../lib/components/buttons/Button.js";
9
- import { getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions } from "./diagnosticsApi.js";
9
+ import { getDiagnosticsInformation, getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions, } from "./diagnosticsApi.js";
10
10
  import { saveTextFile, timestampedFilename } from "./download.js";
11
11
  import { describeError } from "./format.js";
12
+ import InformationPanel from "./InformationPanel.js";
12
13
  import { loadInstalledPlugins } from "./InstalledPlugins.js";
13
14
  import LogsPanel from "./LogsPanel.js";
14
15
  import RuntimePanel from "./RuntimePanel.js";
@@ -16,9 +17,8 @@ import SystemPanel from "./SystemPanel.js";
16
17
  import { useDiagnosticsResource } from "./useDiagnosticsResource.js";
17
18
  import { useLogStream } from "./useLogStream.js";
18
19
  import { useMetricsPolling } from "./useMetricsPolling.js";
19
- import VersionsPanel from "./VersionsPanel.js";
20
20
  const TABS = [
21
- { id: "versions", label: "Versions" },
21
+ { id: "information", label: "Information" },
22
22
  { id: "runtime", label: "Runtime" },
23
23
  { id: "system", label: "System" },
24
24
  { id: "logs", label: "Logs" },
@@ -26,16 +26,17 @@ const TABS = [
26
26
  const tabId = (tab) => `diagnostics-tab-${tab}`;
27
27
  const panelId = (tab) => `diagnostics-panel-${tab}`;
28
28
  /**
29
- * The Diagnostics page: what is installed (Versions), what it runs on (Runtime), how it is doing right now (System, live) and what
30
- * the server is logging (Logs, live, with captures). Versions and Runtime are read when the page opens and again on Refresh, and
29
+ * The Diagnostics page: what is installed and how it is set up (Information), what it runs on (Runtime), how it is doing right now (System, live) and what
30
+ * the server is logging (Logs, live, with captures). Information and Runtime are read when the page opens and again on Refresh, and
31
31
  * are not polled. The live tabs only work while they are open: System polls only on its tab, and the log stream is opened the
32
32
  * first time the Logs tab is (and closes with the page, or on Stop).
33
33
  */
34
34
  export default function DiagnosticsManager({ createLogSocket, saveFile = saveTextFile }) {
35
- const [tab, setTab] = useState("versions");
35
+ const [tab, setTab] = useState("information");
36
36
  const [reloadKey, setReloadKey] = useState(0);
37
37
  const [preparingReport, setPreparingReport] = useState(false);
38
38
  const versions = useDiagnosticsResource(getDiagnosticsVersions, reloadKey, "Could not read the server's versions.");
39
+ const information = useDiagnosticsResource(getDiagnosticsInformation, reloadKey, "Could not read the server's environment and configuration.");
39
40
  const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
40
41
  const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
41
42
  const polling = useMetricsPolling(tab === "system");
@@ -48,7 +49,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
48
49
  startLogs();
49
50
  }
50
51
  }, [tab]);
51
- const refreshing = versions.loading || runtime.loading || plugins.loading;
52
+ const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading;
52
53
  function handleTabKeyDown(event) {
53
54
  const index = TABS.findIndex((item) => item.id === tab);
54
55
  let next;
@@ -71,7 +72,10 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
71
72
  setTab(TABS[next].id);
72
73
  document.getElementById(tabId(TABS[next].id))?.focus();
73
74
  }
74
- /** Saves the versions, the runtime and the latest metrics sample (asking for one if the System tab has not sampled): no logs. */
75
+ /**
76
+ * Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime and the
77
+ * latest metrics sample (asking for one if the System tab has not sampled): no logs.
78
+ */
75
79
  async function downloadReport() {
76
80
  setPreparingReport(true);
77
81
  const generatedAt = new Date();
@@ -88,6 +92,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
88
92
  const report = {
89
93
  generatedAt: generatedAt.toISOString(),
90
94
  versions: versions.data ?? null,
95
+ information: information.data ?? null,
91
96
  runtime: runtime.data ?? null,
92
97
  // Only what each plugin is and which version is loaded: a plugin's settings can hold secrets.
93
98
  plugins: plugins.data
@@ -99,6 +104,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
99
104
  metrics: metrics ?? null,
100
105
  errors: {
101
106
  versions: versions.error ?? null,
107
+ information: information.error ?? null,
102
108
  runtime: runtime.error ?? null,
103
109
  plugins: plugins.error ?? null,
104
110
  metrics: metricsError ?? null,
@@ -107,5 +113,5 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
107
113
  saveFile(timestampedFilename("rapidmx-diagnostics", "json", generatedAt), `${JSON.stringify(report, null, 2)}\n`, "application/json");
108
114
  setPreparingReport(false);
109
115
  }
110
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-5 flex flex-wrap items-center justify-between gap-3", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Diagnostics" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: refreshing, onClick: () => setReloadKey(reloadKey + 1), children: [_jsx(HiOutlineArrowPath, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Refresh"] }), _jsxs(Button, { type: "button", className: "!w-auto", loading: preparingReport, onClick: () => void downloadReport(), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Download diagnostics report"] })] })] }), _jsx("div", { role: "tablist", "aria-label": "Diagnostics sections", className: "mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1", children: TABS.map((item) => (_jsx("button", { type: "button", role: "tab", id: tabId(item.id), "aria-selected": item.id === tab, "aria-controls": panelId(item.id), tabIndex: item.id === tab ? 0 : -1, onClick: () => setTab(item.id), onKeyDown: handleTabKeyDown, className: `flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"}`, children: item.label }, item.id))) }), _jsxs("div", { role: "tabpanel", id: panelId(tab), "aria-labelledby": tabId(tab), children: [tab === "versions" && _jsx(VersionsPanel, { versions: versions, plugins: plugins }), tab === "runtime" && _jsx(RuntimePanel, { runtime: runtime }), tab === "system" && _jsx(SystemPanel, { polling: polling }), tab === "logs" && _jsx(LogsPanel, { stream: logStream, saveFile: saveFile })] })] }));
116
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-5 flex flex-wrap items-center justify-between gap-3", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Diagnostics" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: refreshing, onClick: () => setReloadKey(reloadKey + 1), children: [_jsx(HiOutlineArrowPath, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Refresh"] }), _jsxs(Button, { type: "button", className: "!w-auto", loading: preparingReport, onClick: () => void downloadReport(), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Download diagnostics report"] })] })] }), _jsx("div", { role: "tablist", "aria-label": "Diagnostics sections", className: "mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1", children: TABS.map((item) => (_jsx("button", { type: "button", role: "tab", id: tabId(item.id), "aria-selected": item.id === tab, "aria-controls": panelId(item.id), tabIndex: item.id === tab ? 0 : -1, onClick: () => setTab(item.id), onKeyDown: handleTabKeyDown, className: `flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"}`, children: item.label }, item.id))) }), _jsxs("div", { role: "tabpanel", id: panelId(tab), "aria-labelledby": tabId(tab), children: [tab === "information" && _jsx(InformationPanel, { versions: versions, plugins: plugins, information: information }), tab === "runtime" && _jsx(RuntimePanel, { runtime: runtime }), tab === "system" && _jsx(SystemPanel, { polling: polling }), tab === "logs" && _jsx(LogsPanel, { stream: logStream, saveFile: saveFile })] })] }));
111
117
  }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import type { DiagnosticsInformation, DiagnosticsVersions } from "./diagnosticsApi.js";
3
+ import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
4
+ import { InstalledPluginsData } from "./InstalledPlugins.js";
5
+ export interface InformationPanelProps {
6
+ versions: DiagnosticsResource<DiagnosticsVersions>;
7
+ plugins: DiagnosticsResource<InstalledPluginsData>;
8
+ information: DiagnosticsResource<DiagnosticsInformation>;
9
+ }
10
+ /**
11
+ * The Information tab: the server, the install's other containers, the installed plugins, the server's environment variables and
12
+ * configuration (secrets withheld by the server), and every installed package. The environment and configuration come from their
13
+ * own request, so a server that predates them leaves the rest of the tab working and says so in their place.
14
+ */
15
+ export default function InformationPanel({ versions, plugins, information }: InformationPanelProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Alert from "../../../../../lib/components/feedback/Alert.js";
3
+ import ComponentsTable from "./ComponentsTable.js";
4
+ import InstalledPlugins from "./InstalledPlugins.js";
5
+ import PackagesTable from "./PackagesTable.js";
6
+ import ServerVersionCard from "./ServerVersionCard.js";
7
+ import SettingsTable from "./SettingsTable.js";
8
+ /**
9
+ * The Information tab: the server, the install's other containers, the installed plugins, the server's environment variables and
10
+ * configuration (secrets withheld by the server), and every installed package. The environment and configuration come from their
11
+ * own request, so a server that predates them leaves the rest of the tab working and says so in their place.
12
+ */
13
+ export default function InformationPanel({ versions, plugins, information }) {
14
+ const { data, error, loading } = versions;
15
+ return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Only variables known to be harmless show their value; the value of a secret, or of anything unrecognized, is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Passwords, tokens, keys, certificates and the credentials in URLs are withheld by the server and never sent.", settings: information.data.configuration ?? [] })] })), _jsx(PackagesTable, { packages: data.packages ?? [] })] }))] }));
16
+ }
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import type { DiagnosticsSetting } from "./diagnosticsApi.js";
3
+ /** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
4
+ export declare const SETTINGS_PAGE_SIZE = 100;
5
+ /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
6
+ export declare const HIDDEN_VALUE = "\u2022\u2022\u2022\u2022 (hidden)";
7
+ export interface SettingsTableProps {
8
+ /** The section's heading, which also names its filter box and labels the section. */
9
+ title: string;
10
+ /** What the section lists, under the heading. */
11
+ description: string;
12
+ /** Also the prefix of the heading's element id. */
13
+ id: string;
14
+ settings: DiagnosticsSetting[];
15
+ }
16
+ /**
17
+ * A filterable, paged list of settings (name and value). A value the server withheld is shown as "•••• (hidden)", never as
18
+ * anything it could have held. The filter matches names, and the values that are shown: a hidden value can't be searched for.
19
+ */
20
+ export default function SettingsTable({ title, description, id, settings }: SettingsTableProps): React.JSX.Element;
@@ -0,0 +1,31 @@
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 { useMemo, useState } from "react";
7
+ import Button from "../../../../../lib/components/buttons/Button.js";
8
+ /** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
9
+ export const SETTINGS_PAGE_SIZE = 100;
10
+ /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
11
+ export const HIDDEN_VALUE = "•••• (hidden)";
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
+ /**
14
+ * A filterable, paged list of settings (name and value). A value the server withheld is shown as "•••• (hidden)", never as
15
+ * anything it could have held. The filter matches names, and the values that are shown: a hidden value can't be searched for.
16
+ */
17
+ export default function SettingsTable({ title, description, id, settings }) {
18
+ const [query, setQuery] = useState("");
19
+ const [limit, setLimit] = useState(SETTINGS_PAGE_SIZE);
20
+ const matching = useMemo(() => {
21
+ const needle = query.trim().toLowerCase();
22
+ return settings.filter((item) => needle === "" || `${item.name} ${item.redacted ? "" : (item.value ?? "")}`.toLowerCase().includes(needle));
23
+ }, [settings, query]);
24
+ const shown = matching.slice(0, limit);
25
+ const hiddenCount = settings.filter((item) => item.redacted).length;
26
+ return (_jsxs("section", { "aria-labelledby": `${id}-heading`, className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: `${id}-heading`, className: "text-base font-bold uppercase tracking-wide mb-1", children: title }), _jsx("p", { className: "mb-3 text-xs text-text-muted", children: description }), _jsx("div", { className: "mb-3", children: _jsx("input", { type: "search", "aria-label": `Filter ${title.toLowerCase()}`, className: INPUT_CLASS, placeholder: "Filter by name or value", value: query, onChange: (event) => {
27
+ setQuery(event.target.value);
28
+ setLimit(SETTINGS_PAGE_SIZE);
29
+ } }) }), _jsx("p", { role: "status", className: "mb-2 text-xs text-text-muted", children: (matching.length === settings.length ? `${settings.length} settings` : `${matching.length} of ${settings.length} settings match`) +
30
+ (hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.name }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.redacted ? _jsx("span", { className: "text-text-muted", children: HIDDEN_VALUE }) : item.value })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
31
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The client of the server's `/admin/diagnostics/*` endpoints (`versions`, `runtime`, `metrics`). All three are GET,
2
+ * The client of the server's `/admin/diagnostics/*` endpoints (`versions`, `information`, `runtime`, `metrics`). All four are GET,
3
3
  * trusted-administrator only and `@RequiresElevation()`: an elevated token is required (403 `api-104`, see
4
4
  * `../elevation.ts`), and any other 403 means the caller is not an administrator. The server may leave any optional
5
5
  * field out, so a caller reads them defensively.
@@ -62,6 +62,20 @@ export interface DiagnosticsVersions {
62
62
  reason?: string;
63
63
  };
64
64
  }
65
+ /**
66
+ * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every secret-like
67
+ * or unrecognized value and never sends it, so there is nothing for the client to hide.
68
+ */
69
+ export interface DiagnosticsSetting {
70
+ name: string;
71
+ value?: string;
72
+ redacted: boolean;
73
+ }
74
+ /** The server's environment variables and effective configuration, each sorted by name. */
75
+ export interface DiagnosticsInformation {
76
+ environment: DiagnosticsSetting[];
77
+ configuration: DiagnosticsSetting[];
78
+ }
65
79
  /** A node that runs some of this install's pods, derived from the pods: nothing else about the node is readable. */
66
80
  export interface DiagnosticsNode {
67
81
  name: string;
@@ -195,6 +209,11 @@ export interface DiagnosticsMetrics {
195
209
  }
196
210
  /** What is installed and running: the server process, its packages, and the other containers of the install. */
197
211
  export declare function getDiagnosticsVersions(): Promise<DiagnosticsVersions>;
212
+ /**
213
+ * The server's environment variables and configuration, secrets withheld. A server that predates it answers 404, which the
214
+ * Information tab reports beside the rest of the tab, which still works.
215
+ */
216
+ export declare function getDiagnosticsInformation(): Promise<DiagnosticsInformation>;
198
217
  /** The Kubernetes version, and the nodes the install's pods run on. */
199
218
  export declare function getDiagnosticsRuntime(): Promise<DiagnosticsRuntime>;
200
219
  /** One sample of live resource use. Polled by the System tab. */
@@ -7,6 +7,13 @@ import { apiFetch } from "../../../../../lib/util/api.js";
7
7
  export function getDiagnosticsVersions() {
8
8
  return apiFetch("/admin/diagnostics/versions");
9
9
  }
10
+ /**
11
+ * The server's environment variables and configuration, secrets withheld. A server that predates it answers 404, which the
12
+ * Information tab reports beside the rest of the tab, which still works.
13
+ */
14
+ export function getDiagnosticsInformation() {
15
+ return apiFetch("/admin/diagnostics/information");
16
+ }
10
17
  /** The Kubernetes version, and the nodes the install's pods run on. */
11
18
  export function getDiagnosticsRuntime() {
12
19
  return apiFetch("/admin/diagnostics/runtime");
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ export interface CalendarSearchBoxProps {
3
+ value: string;
4
+ onChange: (value: string) => void;
5
+ /** Enter: run the search now rather than after the typing pause (and, when it has already run, step on to the next result). */
6
+ onSubmit: (backwards: boolean) => void;
7
+ /** The clear button or Escape: leave search mode. */
8
+ onClear: () => void;
9
+ onPrevious: () => void;
10
+ onNext: () => void;
11
+ /** Whether a search is running (its query is not blank); the result count and stepping buttons show only then. */
12
+ active: boolean;
13
+ /** How many occurrences match. */
14
+ count: number;
15
+ /** The current result's place in the list (0-based), `-1` when none is current. */
16
+ position: number;
17
+ inputRef?: React.Ref<HTMLInputElement>;
18
+ }
19
+ /** The text announced (and, compactly, shown) for a search's results. */
20
+ export declare function searchStatusText(count: number, position: number): string;
21
+ /** The calendar toolbar's search: a magnifier, the query, a clear button and - while searching - the match count with Previous/Next match buttons. */
22
+ export default function CalendarSearchBox({ value, onChange, onSubmit, onClear, onPrevious, onNext, active, count, position, inputRef }: CalendarSearchBoxProps): React.JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiOutlineChevronDown, HiOutlineChevronUp, HiOutlineMagnifyingGlass, HiOutlineXMark } from "react-icons/hi2";
3
+ /** The text announced (and, compactly, shown) for a search's results. */
4
+ export function searchStatusText(count, position) {
5
+ if (count === 0) {
6
+ return "No matches";
7
+ }
8
+ return position >= 0 ? `${position + 1} of ${count}` : `${count} ${count === 1 ? "result" : "results"}`;
9
+ }
10
+ /** The calendar toolbar's search: a magnifier, the query, a clear button and - while searching - the match count with Previous/Next match buttons. */
11
+ export default function CalendarSearchBox({ value, onChange, onSubmit, onClear, onPrevious, onNext, active, count, position, inputRef }) {
12
+ const status = searchStatusText(count, position);
13
+ const stepClass = "w-6 h-6 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:hover:bg-transparent";
14
+ return (_jsxs("div", { role: "search", className: "ml-auto flex items-center gap-1 h-8 px-2 min-w-0 flex-1 md:flex-none md:w-80 rounded-sm border border-border bg-surface focus-within:border-primary", children: [_jsx(HiOutlineMagnifyingGlass, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsx("input", { ref: inputRef, type: "text", value: value, onChange: (e) => onChange(e.target.value), onKeyDown: (e) => {
15
+ if (e.key === "Enter") {
16
+ e.preventDefault();
17
+ onSubmit(e.shiftKey);
18
+ }
19
+ else if (e.key === "Escape") {
20
+ e.preventDefault();
21
+ onClear();
22
+ }
23
+ }, placeholder: "Search events", "aria-label": "Search events", autoComplete: "off", className: "flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-muted" }), active && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-xs text-text-muted whitespace-nowrap", children: status }), _jsx("button", { type: "button", className: stepClass, "aria-label": "Previous match", title: "Previous match (Shift+Enter)", disabled: count === 0, onClick: onPrevious, children: _jsx(HiOutlineChevronUp, { size: 16, "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: stepClass, "aria-label": "Next match", title: "Next match (Enter)", disabled: count === 0, onClick: onNext, children: _jsx(HiOutlineChevronDown, { size: 16, "aria-hidden": "true" }) })] })), value !== "" && (_jsx("button", { type: "button", className: stepClass, "aria-label": "Clear search", title: "Clear search (Esc)", onClick: onClear, children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })), _jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: active ? status : "" })] }));
24
+ }
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ export interface EventMatchNavProps {
3
+ onPrevious: () => void;
4
+ onNext: () => void;
5
+ /** Whether there is an event before / after this one to go to (default: yes). A button with none is disabled, and its arrow key does nothing. */
6
+ canPrevious?: boolean;
7
+ canNext?: boolean;
8
+ /** What is stepped through, for the buttons' names: the matches of a search, or the events of the calendar. */
9
+ noun?: "match" | "event";
10
+ }
11
+ /**
12
+ * The Previous / Next match buttons beside an event's card while a search is running: the card moves on to the neighbouring result. They sit
13
+ * inside the dialog (so Tab reaches them and the dialog's focus trap holds) but are positioned against the window - either side of the
14
+ * centered card on a desktop, the bottom corners on a phone, where the card fills the width. The left and right arrow keys do the same unless
15
+ * a field is taking them.
16
+ */
17
+ export default function EventMatchNav({ onPrevious, onNext, canPrevious, canNext, noun }: EventMatchNavProps): React.JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, 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 { useEffect } from "react";
7
+ import { HiOutlineChevronLeft, HiOutlineChevronRight } from "react-icons/hi2";
8
+ import { isTextEntry } from "../../keyboard/targets.js";
9
+ /**
10
+ * The Previous / Next match buttons beside an event's card while a search is running: the card moves on to the neighbouring result. They sit
11
+ * inside the dialog (so Tab reaches them and the dialog's focus trap holds) but are positioned against the window - either side of the
12
+ * centered card on a desktop, the bottom corners on a phone, where the card fills the width. The left and right arrow keys do the same unless
13
+ * a field is taking them.
14
+ */
15
+ export default function EventMatchNav({ onPrevious, onNext, canPrevious = true, canNext = true, noun = "match" }) {
16
+ useEffect(() => {
17
+ function handleKeyDown(event) {
18
+ if ((event.key !== "ArrowLeft" && event.key !== "ArrowRight") || event.defaultPrevented || isTextEntry(event.target)) {
19
+ return;
20
+ }
21
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
22
+ return;
23
+ }
24
+ event.preventDefault();
25
+ if (event.key === "ArrowLeft" ? canPrevious : canNext) {
26
+ (event.key === "ArrowLeft" ? onPrevious : onNext)();
27
+ }
28
+ }
29
+ window.addEventListener("keydown", handleKeyDown);
30
+ return () => window.removeEventListener("keydown", handleKeyDown);
31
+ }, [onPrevious, onNext, canPrevious, canNext]);
32
+ // The card is centered, at most 480px wide (`DETAILS_WIDTH`): 240px either side of the middle, then a gap and the button itself.
33
+ const buttonClass = "fixed bottom-4 md:bottom-auto md:top-1/2 md:-translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-surface border border-border shadow-modal text-text-muted";
34
+ const enabledClass = "hover:bg-surface-alt hover:text-text";
35
+ const disabledClass = "opacity-40 cursor-not-allowed";
36
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": `Previous ${noun}`, "aria-disabled": canPrevious ? undefined : true, title: `Previous ${noun} (Left arrow)`, onClick: canPrevious ? onPrevious : undefined, className: `${buttonClass} ${canPrevious ? enabledClass : disabledClass} left-4 md:left-[calc(50vw-296px)]`, children: _jsx(HiOutlineChevronLeft, { size: 20, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "aria-label": `Next ${noun}`, "aria-disabled": canNext ? undefined : true, title: `Next ${noun} (Right arrow)`, onClick: canNext ? onNext : undefined, className: `${buttonClass} ${canNext ? enabledClass : disabledClass} right-4 md:right-[calc(50vw-296px)]`, children: _jsx(HiOutlineChevronRight, { size: 20, "aria-hidden": "true" }) })] }));
37
+ }
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { Mailbox } from "../../../../lib/mail/mailApi.js";
3
3
  import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
4
4
  import { EventAnchor } from "./EventShell.js";
5
+ import { SlideFrom } from "./SlideIn.js";
5
6
  export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
6
7
  export type { EventAnchor } from "./EventShell.js";
7
8
  export interface EventModalProps {
@@ -48,6 +49,21 @@ export interface EventModalProps {
48
49
  /** Where the booking plugin's Settings pages are (`/settings/booking-types`) when it is running: a new event then has an Appointment
49
50
  * schedule tab, and its More options opens the plugin's own new-link page. Without it there are only the Event and Task tabs. */
50
51
  bookingHref?: string;
52
+ /** The existing event's card gets Previous / Next buttons either side, which call these: the previous or next match while the calendar's
53
+ * search is running, the previous or next event otherwise (`canPrevious` / `canNext` false: none that way, so the button is disabled). */
54
+ matchNav?: {
55
+ onPrevious: () => void;
56
+ onNext: () => void;
57
+ canPrevious?: boolean;
58
+ canNext?: boolean;
59
+ noun?: "match" | "event";
60
+ /** The step that moved the card on, if one did: the card slides in from the side it came from - left for a Previous, right for a Next - when
61
+ * `occurrenceKey` is the event now showing (a card that changed any other way does not slide). */
62
+ slide?: {
63
+ from: SlideFrom;
64
+ occurrenceKey: string;
65
+ };
66
+ };
51
67
  onSaved: () => void;
52
68
  onDeleted: () => void;
53
69
  }
@@ -66,4 +82,4 @@ export interface EventModalProps {
66
82
  * Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
67
83
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
68
84
  */
69
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, onSaved, onDeleted, }: EventModalProps): React.JSX.Element | null;
85
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }: EventModalProps): React.JSX.Element | null;
@@ -9,7 +9,9 @@ import Button from "../../../../lib/components/buttons/Button.js";
9
9
  import useIsMobile from "../../../../lib/util/useIsMobile.js";
10
10
  import EventDetails from "./EventDetails.js";
11
11
  import EventEditor from "./EventEditor.js";
12
+ import EventMatchNav from "./EventMatchNav.js";
12
13
  import EventShell from "./EventShell.js";
14
+ import SlideIn from "./SlideIn.js";
13
15
  export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
14
16
  /** How wide each face of the dialog gets, in px. */
15
17
  const QUICK_WIDTH = 450;
@@ -31,7 +33,7 @@ const TASK_FORM_WIDTH = 560;
31
33
  * Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
32
34
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
33
35
  */
34
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, onSaved, onDeleted, }) {
36
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }) {
35
37
  const isMobile = useIsMobile();
36
38
  // An existing event: Modify was pressed. A new event: More options was.
37
39
  const [editing, setEditing] = useState(false);
@@ -63,14 +65,14 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
63
65
  const width = !isForm ? DETAILS_WIDTH : layout === "expanded" ? (tab === "event" || occurrence ? FORM_WIDTH : TASK_FORM_WIDTH) : QUICK_WIDTH;
64
66
  // Leaving the form: an existing event goes back to its details, a new one is done.
65
67
  const leaveForm = occurrence ? () => setEditing(false) : onClose;
66
- return (_jsx(EventShell, { variant: variant, label: !occurrence ? "New event" : editing ? "Edit event" : "Event details", focusKey: isForm ? layout : "details", anchor: anchor, width: width, onClose: isForm ? leaveForm : onClose, onBackdropPress: isForm ? () => !dirtyRef.current && leaveForm() : undefined, children: isForm ? (_jsx(EventEditor, { layout: layout, onExpand: () => setExpanded(true), onCancel: leaveForm, onSaved: onSaved, onSyncWarning: () => setSyncWarning(true), dirtyRef: dirtyRef, mailboxUid: mailboxUid, folderUid: folderUid, calendars: calendars, mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: organizerAddress, occurrence: occurrence, initialStart: initialStart, initialEnd: initialEnd, initialAllDay: initialAllDay, quickCreate: occurrence
67
- ? undefined
68
- : {
69
- tab,
70
- onTabChange: (next) => {
71
- setTab(next);
72
- setExpanded(false);
73
- },
74
- bookingHref,
75
- } })) : (_jsx(EventDetails, { occurrence: occurrence, isInvited: isInvited, myResponse: canRespond ? myAttendee.responseStatus : undefined, isOwnAddress: isOwnAddress, calendarName: calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name, calendarColor: folderColors?.[occurrence.folderUid], onClose: onClose, onModify: () => setEditing(true), onSaved: onSaved, onDeleted: onDeleted })) }));
68
+ return (_jsxs(EventShell, { variant: variant, label: !occurrence ? "New event" : editing ? "Edit event" : "Event details", focusKey: isForm ? layout : "details", anchor: anchor, width: width, onClose: isForm ? leaveForm : onClose, onBackdropPress: isForm ? () => !dirtyRef.current && leaveForm() : undefined, children: [isForm ? (_jsx(EventEditor, { layout: layout, onExpand: () => setExpanded(true), onCancel: leaveForm, onSaved: onSaved, onSyncWarning: () => setSyncWarning(true), dirtyRef: dirtyRef, mailboxUid: mailboxUid, folderUid: folderUid, calendars: calendars, mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: organizerAddress, occurrence: occurrence, initialStart: initialStart, initialEnd: initialEnd, initialAllDay: initialAllDay, quickCreate: occurrence
69
+ ? undefined
70
+ : {
71
+ tab,
72
+ onTabChange: (next) => {
73
+ setTab(next);
74
+ setExpanded(false);
75
+ },
76
+ bookingHref,
77
+ } })) : (_jsx(SlideIn, { from: matchNav?.slide?.occurrenceKey === occurrence.occurrenceKey ? matchNav.slide.from : undefined, children: _jsx(EventDetails, { occurrence: occurrence, isInvited: isInvited, myResponse: canRespond ? myAttendee.responseStatus : undefined, isOwnAddress: isOwnAddress, calendarName: calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name, calendarColor: folderColors?.[occurrence.folderUid], onClose: onClose, onModify: () => setEditing(true), onSaved: onSaved, onDeleted: onDeleted }) }, occurrence.occurrenceKey)), !isForm && matchNav && _jsx(EventMatchNav, { onPrevious: matchNav.onPrevious, onNext: matchNav.onNext, canPrevious: matchNav.canPrevious, canNext: matchNav.canNext, noun: matchNav.noun })] }));
76
78
  }
@@ -0,0 +1,30 @@
1
+ import React from "react";
2
+ import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
3
+ /** The most days one event is listed on, so an event that runs for years does not become thousands of rows. */
4
+ export declare const LIST_MAX_DAYS_PER_EVENT = 366;
5
+ /** A step through the list's months: `nonce` makes a second press in the same direction a new step. */
6
+ export interface ListStep {
7
+ direction: 1 | -1;
8
+ nonce: number;
9
+ }
10
+ export interface ListViewProps {
11
+ /** Every occurrence to list (or, while a search runs, only its matches) - see `eventSearch.ts`'s `allOccurrences()`. Only today and later is shown. */
12
+ occurrences: CalendarOccurrence[];
13
+ /** Each occurrence's own calendar color, keyed by `folderUid` - see `calendarColors.ts`. */
14
+ folderColors: Record<string, string>;
15
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
16
+ /** The day the list scrolls to (the first day from it that has events - the top for one in the past -, the last day when none does) on opening and whenever `jumpNonce` changes. */
17
+ focusDate: Date;
18
+ jumpNonce: number;
19
+ /** Scrolls to the first day of the previous or next month (from the one at the top) that has events. */
20
+ step: ListStep | null;
21
+ }
22
+ /**
23
+ * Every event from today on as one scrollable list, in date order, with a heading and a list for each day that has any (days without events are left out, and
24
+ * nothing at all says so). A multi-day event is listed on every day it covers, as the month grid shows it. Each row is a full-width button that
25
+ * opens the event's card, with the time, title, place (or organizer) and its calendar's colour.
26
+ *
27
+ * Every day is rendered (an event is a cheap row, and a series is bounded to 500 occurrences - see `eventSearch.ts`); the days are grouped once per
28
+ * change of `occurrences`, not per render. The list scrolls itself to `focusDate` and, on a `step`, to the neighbouring month.
29
+ */
30
+ export default function ListView({ occurrences, folderColors, onSelectEvent, focusDate, jumpNonce, step }: ListViewProps): React.JSX.Element;
@@ -0,0 +1,111 @@
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 { useEffect, useMemo, useRef } from "react";
7
+ import { addDays, format, isToday, parseISO, startOfDay } from "date-fns";
8
+ import { allDayDateKey, localDateKey, startsOnDay } from "./allDay.js";
9
+ import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
10
+ /** The most days one event is listed on, so an event that runs for years does not become thousands of rows. */
11
+ export const LIST_MAX_DAYS_PER_EVENT = 366;
12
+ /** When an occurrence is on show on `day`: its time range, "All day", or - on a later day of a multi-day event - that it continues. */
13
+ function timeLabel(occurrence, day) {
14
+ if (occurrence.allDay) {
15
+ return "All day";
16
+ }
17
+ if (!startsOnDay(occurrence, day)) {
18
+ return "Continues";
19
+ }
20
+ const start = new Date(occurrence.startDate);
21
+ const end = new Date(occurrence.endDate);
22
+ return start.getTime() === end.getTime() ? format(start, "h:mma") : `${format(start, "h:mma")} – ${format(end, "h:mma")}`;
23
+ }
24
+ /** The days (local midnights) `occurrence` is on show on: from its first to its last, at most `LIST_MAX_DAYS_PER_EVENT`. */
25
+ function daysOf(occurrence) {
26
+ let first;
27
+ let last;
28
+ if (occurrence.allDay) {
29
+ // Date-only, the end exclusive (see `allDay.ts`), and at least the start day.
30
+ first = parseISO(allDayDateKey(occurrence.startDate));
31
+ last = addDays(parseISO(allDayDateKey(occurrence.endDate)), -1);
32
+ }
33
+ else {
34
+ const start = new Date(occurrence.startDate);
35
+ const end = new Date(occurrence.endDate);
36
+ first = startOfDay(start);
37
+ // An event that ends at midnight does not reach into that day.
38
+ last = startOfDay(end > start ? new Date(end.getTime() - 1) : start);
39
+ }
40
+ const days = [first];
41
+ for (let day = addDays(first, 1); day <= last && days.length < LIST_MAX_DAYS_PER_EVENT; day = addDays(day, 1)) {
42
+ days.push(day);
43
+ }
44
+ return days;
45
+ }
46
+ /** The day's rows in the order they are read: the all-day events and those carried over from an earlier day, then the timed ones as they start. */
47
+ function sortedForDay(occurrences, day) {
48
+ const rank = (occurrence) => (occurrence.allDay || !startsOnDay(occurrence, day) ? 0 : 1);
49
+ return occurrences.sort((a, b) => rank(a) - rank(b) || new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
50
+ }
51
+ /**
52
+ * Every event from today on as one scrollable list, in date order, with a heading and a list for each day that has any (days without events are left out, and
53
+ * nothing at all says so). A multi-day event is listed on every day it covers, as the month grid shows it. Each row is a full-width button that
54
+ * opens the event's card, with the time, title, place (or organizer) and its calendar's colour.
55
+ *
56
+ * Every day is rendered (an event is a cheap row, and a series is bounded to 500 occurrences - see `eventSearch.ts`); the days are grouped once per
57
+ * change of `occurrences`, not per render. The list scrolls itself to `focusDate` and, on a `step`, to the neighbouring month.
58
+ */
59
+ export default function ListView({ occurrences, folderColors, onSelectEvent, focusDate, jumpNonce, step }) {
60
+ const activeKey = useActiveOccurrenceKey();
61
+ const scrollerRef = useRef(null);
62
+ const groups = useMemo(() => {
63
+ const byDay = new Map();
64
+ const todayKey = localDateKey(new Date());
65
+ for (const occurrence of occurrences) {
66
+ for (const day of daysOf(occurrence)) {
67
+ const key = localDateKey(day);
68
+ // The days an event spent before today are not listed.
69
+ if (key < todayKey) {
70
+ continue;
71
+ }
72
+ const group = byDay.get(key) ?? { day, items: [] };
73
+ group.items.push(occurrence);
74
+ byDay.set(key, group);
75
+ }
76
+ }
77
+ return Array.from(byDay.entries())
78
+ .sort(([a], [b]) => a.localeCompare(b))
79
+ .map(([key, { day, items }]) => ({ key, day, items: sortedForDay(items, day) }));
80
+ }, [occurrences]);
81
+ const dayElements = () => Array.from(scrollerRef.current.querySelectorAll("[data-day]"));
82
+ // On opening and on a jump (Today, a date picked in the mini calendar): the first day from `focusDate` that has events, else the last one.
83
+ useEffect(() => {
84
+ const wanted = localDateKey(focusDate);
85
+ const days = dayElements();
86
+ (days.find((el) => el.dataset.day >= wanted) ?? days[days.length - 1])?.scrollIntoView({ block: "start" });
87
+ }, [jumpNonce]);
88
+ // A step: from the month of the day at the top of the list to the first day of the previous or next month that has any.
89
+ useEffect(() => {
90
+ const days = dayElements();
91
+ if (!step || days.length === 0) {
92
+ return;
93
+ }
94
+ const top = scrollerRef.current.getBoundingClientRect().top;
95
+ const current = days.find((el) => el.getBoundingClientRect().bottom > top) ?? days[days.length - 1];
96
+ const months = Array.from(new Set(days.map((el) => el.dataset.day.slice(0, 7))));
97
+ const month = months[months.indexOf(current.dataset.day.slice(0, 7)) + step.direction];
98
+ // Past the first or last month with events there is nowhere to go.
99
+ days.find((el) => el.dataset.day.startsWith(month))?.scrollIntoView({ block: "start" });
100
+ }, [step]);
101
+ const thisYear = new Date().getFullYear();
102
+ return (_jsx("div", { ref: scrollerRef, role: "region", "aria-label": "List", "data-calendar-scroller": true, className: "flex-1 min-h-0 overflow-y-auto", children: groups.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No upcoming events" })) : (groups.map(({ key, day, items }) => (_jsxs("section", { "data-day": key, "aria-labelledby": `list-day-${key}`, children: [_jsxs("h3", { id: `list-day-${key}`, "aria-current": isToday(day) ? "date" : undefined, className: [
103
+ "sticky top-0 z-10 px-4 py-2 text-sm font-semibold border-b border-border bg-surface-alt text-text border-l-4",
104
+ isToday(day) ? "border-l-primary" : "border-l-transparent",
105
+ ].join(" "), children: [format(day, day.getFullYear() === thisYear ? "EEEE, MMMM d" : "EEEE, MMMM d, yyyy"), isToday(day) && (_jsx("span", { "aria-hidden": "true", className: "ml-2 px-1.5 py-0.5 rounded-full bg-primary/15 text-primary-dark text-xs font-medium", children: "Today" }))] }), _jsx("ul", { children: items.map((occurrence) => (_jsx(ListRow, { occurrence: occurrence, day: day, color: folderColors[occurrence.folderUid], onSelect: onSelectEvent, activeKey: activeKey }, occurrence.occurrenceKey))) })] }, key)))) }));
106
+ }
107
+ function ListRow({ occurrence, day, color, onSelect, activeKey, }) {
108
+ const marker = occurrenceMarker(activeKey, occurrence);
109
+ const place = occurrence.location || occurrence.organizer.displayName || occurrence.organizer.address;
110
+ return (_jsx("li", { className: "border-b border-border/50", children: _jsxs("button", { type: "button", ...marker.attrs, onClick: () => onSelect(occurrence), className: ["w-full min-h-14 px-4 py-2 flex items-center gap-3 text-left hover:bg-surface-alt", marker.className].join(" "), children: [_jsx("span", { className: "w-28 shrink-0 text-xs text-text-muted", children: timeLabel(occurrence, day) }), _jsx("span", { "aria-hidden": "true", className: ["w-3 h-3 rounded-full shrink-0", occurrence.busyStatus === "free" ? "border-2" : ""].join(" "), style: occurrence.busyStatus === "free" ? { borderColor: color } : { backgroundColor: color } }), _jsxs("span", { className: "flex-1 min-w-0", children: [_jsx("span", { className: "block text-sm font-medium truncate", children: occurrence.title }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: place })] })] }) }));
111
+ }