@rapidmx/web-client 0.28.0 → 0.30.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 (125) 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 +38 -5
  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/SwipeRow.tsx +26 -4
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  24. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  25. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  26. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  27. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  28. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  29. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  30. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  31. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  32. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  33. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  34. package/apps/shared/gestures/useLongPress.ts +138 -0
  35. package/apps/shared/keyboard/keymap.ts +2 -0
  36. package/apps/shared/mail/rangeSelection.ts +45 -0
  37. package/apps/shared/mail/rowExit.ts +39 -0
  38. package/apps/www/calendar/index.tsx +251 -53
  39. package/apps/www/index.tsx +570 -358
  40. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  41. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  42. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  43. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  44. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  45. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  46. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  47. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  48. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  49. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  50. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  51. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  52. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  53. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  54. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  55. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  56. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  57. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  58. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  59. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  60. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  61. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  62. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  63. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  64. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  65. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  66. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  67. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  68. package/dist/apps/shared/components/mail/ConversationList.d.ts +16 -3
  69. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  70. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  71. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  72. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  73. package/dist/apps/shared/components/mail/SwipeRow.d.ts +5 -1
  74. package/dist/apps/shared/components/mail/SwipeRow.js +17 -3
  75. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  76. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  77. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  78. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  79. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  80. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  81. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  82. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  83. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  84. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  85. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  86. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  87. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  88. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  89. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  90. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  91. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  92. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  93. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  94. package/dist/apps/shared/gestures/useLongPress.d.ts +41 -0
  95. package/dist/apps/shared/gestures/useLongPress.js +112 -0
  96. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  97. package/dist/apps/shared/keyboard/keymap.js +2 -0
  98. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  99. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  100. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  101. package/dist/apps/shared/mail/rowExit.js +33 -0
  102. package/dist/apps/www/calendar/index.js +177 -18
  103. package/dist/apps/www/index.js +257 -73
  104. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  105. package/dist/lib/calendar/eventSearch.js +152 -0
  106. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  107. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  108. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  109. package/dist/lib/components/navigation/NavBadge.js +18 -0
  110. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  111. package/dist/lib/mail/compose/quotedImages.js +41 -0
  112. package/dist/lib/mail/inlineImages.d.ts +38 -0
  113. package/dist/lib/mail/inlineImages.js +90 -0
  114. package/dist/lib/mail/mailApi.d.ts +8 -1
  115. package/dist/lib/mail/mailApi.js +57 -5
  116. package/lib/calendar/eventSearch.ts +172 -0
  117. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  118. package/lib/components/navigation/NavBadge.tsx +33 -0
  119. package/lib/mail/compose/quotedImages.ts +49 -0
  120. package/lib/mail/inlineImages.ts +104 -0
  121. package/lib/mail/mailApi.ts +64 -5
  122. package/package.json +1 -1
  123. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  124. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  125. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
package/README.md CHANGED
@@ -364,6 +364,7 @@ any release.
364
364
  | `shared/components/layout/ResponsiveToolbar.js` | A toolbar whose buttons collapse into an overflow menu when space runs out. |
365
365
  | `shared/components/admin/usePagedList.js` | Loads a list a page at a time with "Load more", for tables of the plugin's own records. |
366
366
  | `shared/notifications/apiErrors.js` | `notifyApiError()`, which shows a failed API call as a notification. |
367
+ | `shared/components/mail/compose/RecipientInput.js` | Compose's address field: chips, and suggestions from the caller's contacts and the server directory as they type. Pass `fetchSuggestions` to narrow them (for example to `searchDirectory()` entries of kind `user`, people with an account). |
367
368
  | `shared/components/admin/diagnostics/Sparkline.js` and `MetricTile.js` | A small trend line and a labelled figure, for simple dashboards. |
368
369
 
369
370
  Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
@@ -408,6 +409,8 @@ section above):
408
409
  - `components/forms/FormField.js`;
409
410
  - `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
410
411
  - `components/pickers/MiniDatePicker.js`;
412
+ - `components/pickers/TimeZonePicker.js`, with `util/timeZone.js`'s `timeZoneOptions()` for its `zones`;
413
+ - `mail/directoryApi.js`: `searchDirectory()` and `fetchRecipientSuggestions()`, the address lookups compose uses;
411
414
  - `components/avatar/ContactAvatar.js`;
412
415
  - `util/apiQuery.js`: `buildQuery()`, for paging a list route.
413
416
 
@@ -5,9 +5,16 @@
5
5
  import React, { KeyboardEvent, useEffect, useRef, useState } from "react";
6
6
  import { HiOutlineArrowDownTray, HiOutlineArrowPath } from "react-icons/hi2";
7
7
  import Button from "../../../../../lib/components/buttons/Button.js";
8
- import { DiagnosticsMetrics, getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions } from "./diagnosticsApi.js";
8
+ import {
9
+ DiagnosticsMetrics,
10
+ getDiagnosticsInformation,
11
+ getDiagnosticsMetrics,
12
+ getDiagnosticsRuntime,
13
+ getDiagnosticsVersions,
14
+ } from "./diagnosticsApi.js";
9
15
  import { saveTextFile, SaveFile, timestampedFilename } from "./download.js";
10
16
  import { describeError } from "./format.js";
17
+ import InformationPanel from "./InformationPanel.js";
11
18
  import { loadInstalledPlugins } from "./InstalledPlugins.js";
12
19
  import type { LogSocketFactory } from "./logClient.js";
13
20
  import LogsPanel from "./LogsPanel.js";
@@ -16,12 +23,11 @@ import SystemPanel from "./SystemPanel.js";
16
23
  import { useDiagnosticsResource } from "./useDiagnosticsResource.js";
17
24
  import { useLogStream } from "./useLogStream.js";
18
25
  import { useMetricsPolling } from "./useMetricsPolling.js";
19
- import VersionsPanel from "./VersionsPanel.js";
20
26
 
21
- export type DiagnosticsTab = "versions" | "runtime" | "system" | "logs";
27
+ export type DiagnosticsTab = "information" | "runtime" | "system" | "logs";
22
28
 
23
29
  const TABS: { id: DiagnosticsTab; label: string }[] = [
24
- { id: "versions", label: "Versions" },
30
+ { id: "information", label: "Information" },
25
31
  { id: "runtime", label: "Runtime" },
26
32
  { id: "system", label: "System" },
27
33
  { id: "logs", label: "Logs" },
@@ -38,16 +44,17 @@ export interface DiagnosticsManagerProps {
38
44
  }
39
45
 
40
46
  /**
41
- * The Diagnostics page: what is installed (Versions), what it runs on (Runtime), how it is doing right now (System, live) and what
42
- * the server is logging (Logs, live, with captures). Versions and Runtime are read when the page opens and again on Refresh, and
47
+ * 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
48
+ * the server is logging (Logs, live, with captures). Information and Runtime are read when the page opens and again on Refresh, and
43
49
  * 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
44
50
  * first time the Logs tab is (and closes with the page, or on Stop).
45
51
  */
46
52
  export default function DiagnosticsManager({ createLogSocket, saveFile = saveTextFile }: DiagnosticsManagerProps) {
47
- const [tab, setTab] = useState<DiagnosticsTab>("versions");
53
+ const [tab, setTab] = useState<DiagnosticsTab>("information");
48
54
  const [reloadKey, setReloadKey] = useState(0);
49
55
  const [preparingReport, setPreparingReport] = useState(false);
50
56
  const versions = useDiagnosticsResource(getDiagnosticsVersions, reloadKey, "Could not read the server's versions.");
57
+ const information = useDiagnosticsResource(getDiagnosticsInformation, reloadKey, "Could not read the server's environment and configuration.");
51
58
  const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
52
59
  const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
53
60
  const polling = useMetricsPolling(tab === "system");
@@ -62,7 +69,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
62
69
  }
63
70
  }, [tab]);
64
71
 
65
- const refreshing = versions.loading || runtime.loading || plugins.loading;
72
+ const refreshing = versions.loading || information.loading || runtime.loading || plugins.loading;
66
73
 
67
74
  function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
68
75
  const index = TABS.findIndex((item) => item.id === tab);
@@ -83,7 +90,10 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
83
90
  document.getElementById(tabId(TABS[next].id))?.focus();
84
91
  }
85
92
 
86
- /** Saves the versions, the runtime and the latest metrics sample (asking for one if the System tab has not sampled): no logs. */
93
+ /**
94
+ * Saves the versions, the environment and configuration (the server has already withheld every secret), the runtime and the
95
+ * latest metrics sample (asking for one if the System tab has not sampled): no logs.
96
+ */
87
97
  async function downloadReport() {
88
98
  setPreparingReport(true);
89
99
  const generatedAt = new Date();
@@ -99,6 +109,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
99
109
  const report = {
100
110
  generatedAt: generatedAt.toISOString(),
101
111
  versions: versions.data ?? null,
112
+ information: information.data ?? null,
102
113
  runtime: runtime.data ?? null,
103
114
  // Only what each plugin is and which version is loaded: a plugin's settings can hold secrets.
104
115
  plugins: plugins.data
@@ -110,6 +121,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
110
121
  metrics: metrics ?? null,
111
122
  errors: {
112
123
  versions: versions.error ?? null,
124
+ information: information.error ?? null,
113
125
  runtime: runtime.error ?? null,
114
126
  plugins: plugins.error ?? null,
115
127
  metrics: metricsError ?? null,
@@ -163,7 +175,7 @@ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTex
163
175
  </div>
164
176
 
165
177
  <div role="tabpanel" id={panelId(tab)} aria-labelledby={tabId(tab)}>
166
- {tab === "versions" && <VersionsPanel versions={versions} plugins={plugins} />}
178
+ {tab === "information" && <InformationPanel versions={versions} plugins={plugins} information={information} />}
167
179
  {tab === "runtime" && <RuntimePanel runtime={runtime} />}
168
180
  {tab === "system" && <SystemPanel polling={polling} />}
169
181
  {tab === "logs" && <LogsPanel stream={logStream} saveFile={saveFile} />}
@@ -0,0 +1,60 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Alert from "../../../../../lib/components/feedback/Alert.js";
7
+ import type { DiagnosticsInformation, DiagnosticsVersions } from "./diagnosticsApi.js";
8
+ import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
9
+ import ComponentsTable from "./ComponentsTable.js";
10
+ import InstalledPlugins, { InstalledPluginsData } from "./InstalledPlugins.js";
11
+ import PackagesTable from "./PackagesTable.js";
12
+ import ServerVersionCard from "./ServerVersionCard.js";
13
+ import SettingsTable from "./SettingsTable.js";
14
+
15
+ export interface InformationPanelProps {
16
+ versions: DiagnosticsResource<DiagnosticsVersions>;
17
+ plugins: DiagnosticsResource<InstalledPluginsData>;
18
+ information: DiagnosticsResource<DiagnosticsInformation>;
19
+ }
20
+
21
+ /**
22
+ * The Information tab: the server, the install's other containers, the installed plugins, the server's environment variables and
23
+ * configuration (secrets withheld by the server), and every installed package. The environment and configuration come from their
24
+ * own request, so a server that predates them leaves the rest of the tab working and says so in their place.
25
+ */
26
+ export default function InformationPanel({ versions, plugins, information }: InformationPanelProps) {
27
+ const { data, error, loading } = versions;
28
+ return (
29
+ <div className="space-y-6">
30
+ {error && <Alert>{error}</Alert>}
31
+ {!data && loading && <p className="text-sm text-text-muted">Loading&hellip;</p>}
32
+ {data && (
33
+ <>
34
+ <ServerVersionCard server={data.server} />
35
+ <ComponentsTable components={data.components ?? []} kubernetes={data.kubernetes ?? { available: false }} />
36
+ <InstalledPlugins data={plugins.data} error={plugins.error} loading={plugins.loading} />
37
+ {information.error && <Alert>{information.error}</Alert>}
38
+ {!information.data && information.loading && <p className="text-sm text-text-muted">Loading the environment&hellip;</p>}
39
+ {information.data && (
40
+ <>
41
+ <SettingsTable
42
+ id="diagnostics-environment"
43
+ title="Environment variables"
44
+ 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."
45
+ settings={information.data.environment ?? []}
46
+ />
47
+ <SettingsTable
48
+ id="diagnostics-configuration"
49
+ title="Configuration"
50
+ 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."
51
+ settings={information.data.configuration ?? []}
52
+ />
53
+ </>
54
+ )}
55
+ <PackagesTable packages={data.packages ?? []} />
56
+ </>
57
+ )}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,108 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useMemo, useState } from "react";
6
+ import Button from "../../../../../lib/components/buttons/Button.js";
7
+ import type { DiagnosticsSetting } from "./diagnosticsApi.js";
8
+
9
+ /** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
10
+ export const SETTINGS_PAGE_SIZE = 100;
11
+
12
+ /** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
13
+ export const HIDDEN_VALUE = "•••• (hidden)";
14
+
15
+ const INPUT_CLASS =
16
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
+
18
+ export interface SettingsTableProps {
19
+ /** The section's heading, which also names its filter box and labels the section. */
20
+ title: string;
21
+ /** What the section lists, under the heading. */
22
+ description: string;
23
+ /** Also the prefix of the heading's element id. */
24
+ id: string;
25
+ settings: DiagnosticsSetting[];
26
+ }
27
+
28
+ /**
29
+ * A filterable, paged list of settings (name and value). A value the server withheld is shown as "•••• (hidden)", never as
30
+ * anything it could have held. The filter matches names, and the values that are shown: a hidden value can't be searched for.
31
+ */
32
+ export default function SettingsTable({ title, description, id, settings }: SettingsTableProps) {
33
+ const [query, setQuery] = useState("");
34
+ const [limit, setLimit] = useState(SETTINGS_PAGE_SIZE);
35
+
36
+ const matching = useMemo(() => {
37
+ const needle = query.trim().toLowerCase();
38
+ return settings.filter((item) => needle === "" || `${item.name} ${item.redacted ? "" : (item.value ?? "")}`.toLowerCase().includes(needle));
39
+ }, [settings, query]);
40
+ const shown = matching.slice(0, limit);
41
+ const hiddenCount = settings.filter((item) => item.redacted).length;
42
+
43
+ return (
44
+ <section aria-labelledby={`${id}-heading`} className="rounded-md border border-border bg-surface p-4">
45
+ <h2 id={`${id}-heading`} className="text-base font-bold uppercase tracking-wide mb-1">
46
+ {title}
47
+ </h2>
48
+ <p className="mb-3 text-xs text-text-muted">{description}</p>
49
+ <div className="mb-3">
50
+ <input
51
+ type="search"
52
+ aria-label={`Filter ${title.toLowerCase()}`}
53
+ className={INPUT_CLASS}
54
+ placeholder="Filter by name or value"
55
+ value={query}
56
+ onChange={(event) => {
57
+ setQuery(event.target.value);
58
+ setLimit(SETTINGS_PAGE_SIZE);
59
+ }}
60
+ />
61
+ </div>
62
+ <p role="status" className="mb-2 text-xs text-text-muted">
63
+ {(matching.length === settings.length ? `${settings.length} settings` : `${matching.length} of ${settings.length} settings match`) +
64
+ (hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "")}
65
+ </p>
66
+ {matching.length === 0 ? (
67
+ <p className="text-sm text-text-muted">{settings.length === 0 ? "Nothing is set." : "No settings match."}</p>
68
+ ) : (
69
+ <div className="overflow-x-auto">
70
+ <table className="w-full text-sm border-collapse">
71
+ <thead>
72
+ <tr>
73
+ {["Name", "Value"].map((heading) => (
74
+ <th
75
+ key={heading}
76
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
77
+ >
78
+ {heading}
79
+ </th>
80
+ ))}
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ {shown.map((item) => (
85
+ <tr key={item.name}>
86
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">{item.name}</td>
87
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">
88
+ {item.redacted ? <span className="text-text-muted">{HIDDEN_VALUE}</span> : item.value}
89
+ </td>
90
+ </tr>
91
+ ))}
92
+ </tbody>
93
+ </table>
94
+ </div>
95
+ )}
96
+ {shown.length < matching.length && (
97
+ <div className="mt-3 flex items-center gap-3">
98
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLimit(limit + SETTINGS_PAGE_SIZE)}>
99
+ Show more
100
+ </Button>
101
+ <span className="text-xs text-text-muted">
102
+ Showing {shown.length} of {matching.length}
103
+ </span>
104
+ </div>
105
+ )}
106
+ </section>
107
+ );
108
+ }
@@ -5,7 +5,7 @@
5
5
  import { apiFetch } from "../../../../../lib/util/api.js";
6
6
 
7
7
  /**
8
- * The client of the server's `/admin/diagnostics/*` endpoints (`versions`, `runtime`, `metrics`). All three are GET,
8
+ * The client of the server's `/admin/diagnostics/*` endpoints (`versions`, `information`, `runtime`, `metrics`). All four are GET,
9
9
  * trusted-administrator only and `@RequiresElevation()`: an elevated token is required (403 `api-104`, see
10
10
  * `../elevation.ts`), and any other 403 means the caller is not an administrator. The server may leave any optional
11
11
  * field out, so a caller reads them defensively.
@@ -75,6 +75,22 @@ export interface DiagnosticsVersions {
75
75
  kubernetes: { available: boolean; reason?: string };
76
76
  }
77
77
 
78
+ /**
79
+ * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every secret-like
80
+ * or unrecognized value and never sends it, so there is nothing for the client to hide.
81
+ */
82
+ export interface DiagnosticsSetting {
83
+ name: string;
84
+ value?: string;
85
+ redacted: boolean;
86
+ }
87
+
88
+ /** The server's environment variables and effective configuration, each sorted by name. */
89
+ export interface DiagnosticsInformation {
90
+ environment: DiagnosticsSetting[];
91
+ configuration: DiagnosticsSetting[];
92
+ }
93
+
78
94
  /** A node that runs some of this install's pods, derived from the pods: nothing else about the node is readable. */
79
95
  export interface DiagnosticsNode {
80
96
  name: string;
@@ -205,6 +221,14 @@ export function getDiagnosticsVersions(): Promise<DiagnosticsVersions> {
205
221
  return apiFetch<DiagnosticsVersions>("/admin/diagnostics/versions");
206
222
  }
207
223
 
224
+ /**
225
+ * The server's environment variables and configuration, secrets withheld. A server that predates it answers 404, which the
226
+ * Information tab reports beside the rest of the tab, which still works.
227
+ */
228
+ export function getDiagnosticsInformation(): Promise<DiagnosticsInformation> {
229
+ return apiFetch<DiagnosticsInformation>("/admin/diagnostics/information");
230
+ }
231
+
208
232
  /** The Kubernetes version, and the nodes the install's pods run on. */
209
233
  export function getDiagnosticsRuntime(): Promise<DiagnosticsRuntime> {
210
234
  return apiFetch<DiagnosticsRuntime>("/admin/diagnostics/runtime");
@@ -0,0 +1,83 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineChevronDown, HiOutlineChevronUp, HiOutlineMagnifyingGlass, HiOutlineXMark } from "react-icons/hi2";
7
+
8
+ export interface CalendarSearchBoxProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ /** Enter: run the search now rather than after the typing pause (and, when it has already run, step on to the next result). */
12
+ onSubmit: (backwards: boolean) => void;
13
+ /** The clear button or Escape: leave search mode. */
14
+ onClear: () => void;
15
+ onPrevious: () => void;
16
+ onNext: () => void;
17
+ /** Whether a search is running (its query is not blank); the result count and stepping buttons show only then. */
18
+ active: boolean;
19
+ /** How many occurrences match. */
20
+ count: number;
21
+ /** The current result's place in the list (0-based), `-1` when none is current. */
22
+ position: number;
23
+ inputRef?: React.Ref<HTMLInputElement>;
24
+ }
25
+
26
+ /** The text announced (and, compactly, shown) for a search's results. */
27
+ export function searchStatusText(count: number, position: number): string {
28
+ if (count === 0) {
29
+ return "No matches";
30
+ }
31
+ return position >= 0 ? `${position + 1} of ${count}` : `${count} ${count === 1 ? "result" : "results"}`;
32
+ }
33
+
34
+ /** The calendar toolbar's search: a magnifier, the query, a clear button and - while searching - the match count with Previous/Next match buttons. */
35
+ export default function CalendarSearchBox({ value, onChange, onSubmit, onClear, onPrevious, onNext, active, count, position, inputRef }: CalendarSearchBoxProps) {
36
+ const status = searchStatusText(count, position);
37
+ 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";
38
+ return (
39
+ <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">
40
+ <HiOutlineMagnifyingGlass size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
41
+ <input
42
+ ref={inputRef}
43
+ type="text"
44
+ value={value}
45
+ onChange={(e) => onChange(e.target.value)}
46
+ onKeyDown={(e) => {
47
+ if (e.key === "Enter") {
48
+ e.preventDefault();
49
+ onSubmit(e.shiftKey);
50
+ } else if (e.key === "Escape") {
51
+ e.preventDefault();
52
+ onClear();
53
+ }
54
+ }}
55
+ placeholder="Search events"
56
+ aria-label="Search events"
57
+ autoComplete="off"
58
+ className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-muted"
59
+ />
60
+ {active && (
61
+ <>
62
+ <span aria-hidden="true" className="shrink-0 text-xs text-text-muted whitespace-nowrap">
63
+ {status}
64
+ </span>
65
+ <button type="button" className={stepClass} aria-label="Previous match" title="Previous match (Shift+Enter)" disabled={count === 0} onClick={onPrevious}>
66
+ <HiOutlineChevronUp size={16} aria-hidden="true" />
67
+ </button>
68
+ <button type="button" className={stepClass} aria-label="Next match" title="Next match (Enter)" disabled={count === 0} onClick={onNext}>
69
+ <HiOutlineChevronDown size={16} aria-hidden="true" />
70
+ </button>
71
+ </>
72
+ )}
73
+ {value !== "" && (
74
+ <button type="button" className={stepClass} aria-label="Clear search" title="Clear search (Esc)" onClick={onClear}>
75
+ <HiOutlineXMark size={16} aria-hidden="true" />
76
+ </button>
77
+ )}
78
+ <span role="status" aria-live="polite" className="sr-only">
79
+ {active ? status : ""}
80
+ </span>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,72 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect } from "react";
6
+ import { HiOutlineChevronLeft, HiOutlineChevronRight } from "react-icons/hi2";
7
+ import { isTextEntry } from "../../keyboard/targets.js";
8
+
9
+ export interface EventMatchNavProps {
10
+ onPrevious: () => void;
11
+ onNext: () => void;
12
+ /** 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. */
13
+ canPrevious?: boolean;
14
+ canNext?: boolean;
15
+ /** What is stepped through, for the buttons' names: the matches of a search, or the events of the calendar. */
16
+ noun?: "match" | "event";
17
+ }
18
+
19
+ /**
20
+ * 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
21
+ * inside the dialog (so Tab reaches them and the dialog's focus trap holds) but are positioned against the window - either side of the
22
+ * 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
23
+ * a field is taking them.
24
+ */
25
+ export default function EventMatchNav({ onPrevious, onNext, canPrevious = true, canNext = true, noun = "match" }: EventMatchNavProps) {
26
+ useEffect(() => {
27
+ function handleKeyDown(event: KeyboardEvent) {
28
+ if ((event.key !== "ArrowLeft" && event.key !== "ArrowRight") || event.defaultPrevented || isTextEntry(event.target)) {
29
+ return;
30
+ }
31
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
32
+ return;
33
+ }
34
+ event.preventDefault();
35
+ if (event.key === "ArrowLeft" ? canPrevious : canNext) {
36
+ (event.key === "ArrowLeft" ? onPrevious : onNext)();
37
+ }
38
+ }
39
+ window.addEventListener("keydown", handleKeyDown);
40
+ return () => window.removeEventListener("keydown", handleKeyDown);
41
+ }, [onPrevious, onNext, canPrevious, canNext]);
42
+
43
+ // The card is centered, at most 480px wide (`DETAILS_WIDTH`): 240px either side of the middle, then a gap and the button itself.
44
+ const buttonClass =
45
+ "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";
46
+ const enabledClass = "hover:bg-surface-alt hover:text-text";
47
+ const disabledClass = "opacity-40 cursor-not-allowed";
48
+ return (
49
+ <>
50
+ <button
51
+ type="button"
52
+ aria-label={`Previous ${noun}`}
53
+ aria-disabled={canPrevious ? undefined : true}
54
+ title={`Previous ${noun} (Left arrow)`}
55
+ onClick={canPrevious ? onPrevious : undefined}
56
+ className={`${buttonClass} ${canPrevious ? enabledClass : disabledClass} left-4 md:left-[calc(50vw-296px)]`}
57
+ >
58
+ <HiOutlineChevronLeft size={20} aria-hidden="true" />
59
+ </button>
60
+ <button
61
+ type="button"
62
+ aria-label={`Next ${noun}`}
63
+ aria-disabled={canNext ? undefined : true}
64
+ title={`Next ${noun} (Right arrow)`}
65
+ onClick={canNext ? onNext : undefined}
66
+ className={`${buttonClass} ${canNext ? enabledClass : disabledClass} right-4 md:right-[calc(50vw-296px)]`}
67
+ >
68
+ <HiOutlineChevronRight size={20} aria-hidden="true" />
69
+ </button>
70
+ </>
71
+ );
72
+ }
@@ -10,7 +10,9 @@ import Button from "../../../../lib/components/buttons/Button.js";
10
10
  import useIsMobile from "../../../../lib/util/useIsMobile.js";
11
11
  import EventDetails from "./EventDetails.js";
12
12
  import EventEditor from "./EventEditor.js";
13
+ import EventMatchNav from "./EventMatchNav.js";
13
14
  import EventShell, { EventAnchor, EventShellVariant } from "./EventShell.js";
15
+ import SlideIn, { SlideFrom } from "./SlideIn.js";
14
16
  import { QuickTab } from "./QuickCreateTabs.js";
15
17
 
16
18
  export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
@@ -51,6 +53,18 @@ export interface EventModalProps {
51
53
  /** Where the booking plugin's Settings pages are (`/settings/booking-types`) when it is running: a new event then has an Appointment
52
54
  * schedule tab, and its More options opens the plugin's own new-link page. Without it there are only the Event and Task tabs. */
53
55
  bookingHref?: string;
56
+ /** The existing event's card gets Previous / Next buttons either side, which call these: the previous or next match while the calendar's
57
+ * search is running, the previous or next event otherwise (`canPrevious` / `canNext` false: none that way, so the button is disabled). */
58
+ matchNav?: {
59
+ onPrevious: () => void;
60
+ onNext: () => void;
61
+ canPrevious?: boolean;
62
+ canNext?: boolean;
63
+ noun?: "match" | "event";
64
+ /** 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
65
+ * `occurrenceKey` is the event now showing (a card that changed any other way does not slide). */
66
+ slide?: { from: SlideFrom; occurrenceKey: string };
67
+ };
54
68
  onSaved: () => void;
55
69
  onDeleted: () => void;
56
70
  }
@@ -92,6 +106,7 @@ export default function EventModal({
92
106
  initialAllDay,
93
107
  anchor,
94
108
  bookingHref,
109
+ matchNav,
95
110
  onSaved,
96
111
  onDeleted,
97
112
  }: EventModalProps) {
@@ -188,19 +203,25 @@ export default function EventModal({
188
203
  }
189
204
  />
190
205
  ) : (
191
- <EventDetails
192
- occurrence={occurrence}
193
- isInvited={isInvited}
194
- myResponse={canRespond ? myAttendee.responseStatus : undefined}
195
- isOwnAddress={isOwnAddress}
196
- calendarName={calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name}
197
- calendarColor={folderColors?.[occurrence.folderUid]}
198
- onClose={onClose}
199
- onModify={() => setEditing(true)}
200
- onSaved={onSaved}
201
- onDeleted={onDeleted}
202
- />
206
+ <SlideIn
207
+ key={occurrence.occurrenceKey}
208
+ from={matchNav?.slide?.occurrenceKey === occurrence.occurrenceKey ? matchNav.slide.from : undefined}
209
+ >
210
+ <EventDetails
211
+ occurrence={occurrence}
212
+ isInvited={isInvited}
213
+ myResponse={canRespond ? myAttendee.responseStatus : undefined}
214
+ isOwnAddress={isOwnAddress}
215
+ calendarName={calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name}
216
+ calendarColor={folderColors?.[occurrence.folderUid]}
217
+ onClose={onClose}
218
+ onModify={() => setEditing(true)}
219
+ onSaved={onSaved}
220
+ onDeleted={onDeleted}
221
+ />
222
+ </SlideIn>
203
223
  )}
224
+ {!isForm && matchNav && <EventMatchNav onPrevious={matchNav.onPrevious} onNext={matchNav.onNext} canPrevious={matchNav.canPrevious} canNext={matchNav.canNext} noun={matchNav.noun} />}
204
225
  </EventShell>
205
226
  );
206
227
  }