nexus-shared 3.2.0 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +2359 -0
  2. package/README.md +1911 -1123
  3. package/dist/Client.Index.d.ts +27 -0
  4. package/dist/Client.Index.js +27 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  22. package/dist/Components/Documents/DocumentFrame.js +83 -0
  23. package/dist/Components/Documents/Menu.d.ts +4 -0
  24. package/dist/Components/Documents/Menu.js +40 -24
  25. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  26. package/dist/Components/Documents/PrintDocument.js +62 -0
  27. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  28. package/dist/Components/Documents/TabButtons.js +5 -4
  29. package/dist/Components/Documents/Tabs.d.ts +59 -0
  30. package/dist/Components/Documents/Tabs.js +70 -0
  31. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  32. package/dist/Components/Domains/DomainCard.js +182 -0
  33. package/dist/Components/Forms/ApiForm.js +24 -3
  34. package/dist/Components/Forms/Crud.d.ts +2 -2
  35. package/dist/Components/Forms/Crud.js +628 -247
  36. package/dist/Components/Forms/CrudActions.d.ts +137 -0
  37. package/dist/Components/Forms/CrudActions.js +214 -0
  38. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  39. package/dist/Components/Forms/CrudHistory.js +14 -6
  40. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  41. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  42. package/dist/Components/Forms/CrudParts.d.ts +37 -2
  43. package/dist/Components/Forms/CrudParts.js +123 -7
  44. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  45. package/dist/Components/Forms/CrudRowForms.js +174 -0
  46. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  47. package/dist/Components/Forms/CrudScopes.js +76 -0
  48. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  49. package/dist/Components/Forms/CrudTree.js +363 -0
  50. package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
  51. package/dist/Components/Forms/CrudWorkspace.js +177 -0
  52. package/dist/Components/Forms/ExcelImport.js +3 -3
  53. package/dist/Components/Forms/Form.d.ts +6 -5
  54. package/dist/Components/Forms/Form.js +134 -21
  55. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  56. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  57. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  58. package/dist/Components/Forms/FormQuestion.js +28 -0
  59. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  60. package/dist/Components/Forms/RecordFlows.js +282 -0
  61. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  62. package/dist/Components/Forms/SubmitForm.js +182 -10
  63. package/dist/Components/Inputs/CheckBox.js +1 -1
  64. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  65. package/dist/Components/Inputs/ChoiceField.js +28 -2
  66. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  67. package/dist/Components/Inputs/ComputedInput.js +179 -0
  68. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  69. package/dist/Components/Inputs/Dropdown.js +8 -6
  70. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  71. package/dist/Components/Inputs/DropdownParts.js +16 -4
  72. package/dist/Components/Inputs/Field.d.ts +7 -1
  73. package/dist/Components/Inputs/Field.js +2 -2
  74. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  75. package/dist/Components/Inputs/GroupForm.js +12 -12
  76. package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
  77. package/dist/Components/Inputs/InputRenderer.js +37 -32
  78. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  79. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  80. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  81. package/dist/Components/Inputs/PictureInput.js +75 -0
  82. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  83. package/dist/Components/Inputs/RadioGroup.js +8 -3
  84. package/dist/Components/Inputs/RowsInput.d.ts +15 -2
  85. package/dist/Components/Inputs/RowsInput.js +96 -8
  86. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  87. package/dist/Components/Inputs/TabularForm.js +45 -32
  88. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  89. package/dist/Components/Inputs/TextArea.js +11 -2
  90. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  91. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  92. package/dist/Components/Inputs/TreeView.d.ts +76 -2
  93. package/dist/Components/Inputs/TreeView.js +227 -16
  94. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  95. package/dist/Components/Layouts/MessageBox.js +56 -7
  96. package/dist/Components/Layouts/Popup.d.ts +34 -7
  97. package/dist/Components/Layouts/Popup.js +143 -13
  98. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  99. package/dist/Components/Layouts/PopupDock.js +2 -1
  100. package/dist/Components/Layouts/PopupHost.js +5 -4
  101. package/dist/Components/Messages/MailClient.d.ts +50 -0
  102. package/dist/Components/Messages/MailClient.js +410 -0
  103. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  104. package/dist/Components/Messages/MailColumns.js +92 -0
  105. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  106. package/dist/Components/Messages/MailCompose.js +154 -0
  107. package/dist/Components/Messages/MailReader.d.ts +43 -0
  108. package/dist/Components/Messages/MailReader.js +207 -0
  109. package/dist/Components/Messages/MailThread.d.ts +71 -0
  110. package/dist/Components/Messages/MailThread.js +185 -0
  111. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  112. package/dist/Components/Messages/MessageBell.js +76 -0
  113. package/dist/Components/Viewers/DataTable.js +153 -49
  114. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  115. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  116. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  117. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  118. package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
  119. package/dist/Components/Viewers/DataTableParts.js +26 -14
  120. package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
  121. package/dist/Components/Viewers/OwnerFiles.js +700 -0
  122. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  123. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  124. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  125. package/dist/Components/Viewers/RecordCards.js +509 -0
  126. package/dist/Helpers/ApiModules.js +4 -0
  127. package/dist/Helpers/AuditEntries.d.ts +50 -0
  128. package/dist/Helpers/AuditEntries.js +96 -0
  129. package/dist/Helpers/BusyHooks.d.ts +6 -0
  130. package/dist/Helpers/BusyHooks.js +39 -0
  131. package/dist/Helpers/ChatBackend.d.ts +8 -1
  132. package/dist/Helpers/ChatBackend.js +18 -0
  133. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  134. package/dist/Helpers/ChatHelpers.js +4 -1
  135. package/dist/Helpers/CrudBackend.d.ts +20 -2
  136. package/dist/Helpers/CrudBackend.js +46 -2
  137. package/dist/Helpers/CrudColumns.d.ts +91 -6
  138. package/dist/Helpers/CrudColumns.js +148 -7
  139. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  140. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  141. package/dist/Helpers/CrudHelpers.d.ts +116 -2
  142. package/dist/Helpers/CrudHelpers.js +256 -0
  143. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  144. package/dist/Helpers/CrudNarrowPick.js +157 -0
  145. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  146. package/dist/Helpers/CrudPickerPlan.js +46 -0
  147. package/dist/Helpers/CrudRowForms.d.ts +32 -0
  148. package/dist/Helpers/CrudRowForms.js +76 -0
  149. package/dist/Helpers/CrudScopes.d.ts +32 -0
  150. package/dist/Helpers/CrudScopes.js +79 -0
  151. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  152. package/dist/Helpers/DashboardHelpers.js +305 -0
  153. package/dist/Helpers/DomainCards.d.ts +140 -0
  154. package/dist/Helpers/DomainCards.js +370 -0
  155. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  156. package/dist/Helpers/DropdownHelpers.js +6 -2
  157. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  158. package/dist/Helpers/ExcelHelpers.js +12 -11
  159. package/dist/Helpers/FileAdapter.d.ts +158 -0
  160. package/dist/Helpers/FileAdapter.js +173 -0
  161. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  162. package/dist/Helpers/FilePrimaries.js +138 -0
  163. package/dist/Helpers/FormConfirm.d.ts +32 -0
  164. package/dist/Helpers/FormConfirm.js +40 -0
  165. package/dist/Helpers/FormStore.d.ts +100 -5
  166. package/dist/Helpers/FormStore.js +368 -25
  167. package/dist/Helpers/InputConditions.d.ts +56 -0
  168. package/dist/Helpers/InputConditions.js +192 -0
  169. package/dist/Helpers/InputMemory.d.ts +106 -0
  170. package/dist/Helpers/InputMemory.js +395 -0
  171. package/dist/Helpers/InputNarrowing.d.ts +50 -0
  172. package/dist/Helpers/InputNarrowing.js +148 -0
  173. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  174. package/dist/Helpers/InputParamsHelpers.js +157 -74
  175. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  176. package/dist/Helpers/MailClientHelpers.js +211 -0
  177. package/dist/Helpers/MessageBackend.d.ts +152 -0
  178. package/dist/Helpers/MessageBackend.js +329 -0
  179. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  180. package/dist/Helpers/MessageHelpers.js +573 -0
  181. package/dist/Helpers/MessageUnread.d.ts +95 -0
  182. package/dist/Helpers/MessageUnread.js +245 -0
  183. package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
  184. package/dist/Helpers/NexusDriveAdapter.js +236 -0
  185. package/dist/Helpers/NumberHelpers.d.ts +156 -0
  186. package/dist/Helpers/NumberHelpers.js +242 -0
  187. package/dist/Helpers/PagedPicker.d.ts +42 -0
  188. package/dist/Helpers/PagedPicker.js +85 -0
  189. package/dist/Helpers/Permissions.js +2 -1
  190. package/dist/Helpers/QrCode.d.ts +26 -0
  191. package/dist/Helpers/QrCode.js +463 -0
  192. package/dist/Helpers/RecordCards.d.ts +205 -0
  193. package/dist/Helpers/RecordCards.js +378 -0
  194. package/dist/Helpers/RequestCache.d.ts +14 -2
  195. package/dist/Helpers/RequestCache.js +22 -3
  196. package/dist/Helpers/RowsStore.d.ts +61 -2
  197. package/dist/Helpers/RowsStore.js +203 -16
  198. package/dist/Helpers/ServerPreview.d.ts +69 -0
  199. package/dist/Helpers/ServerPreview.js +99 -0
  200. package/dist/Helpers/TableColumns.js +4 -3
  201. package/dist/Helpers/TableExport.d.ts +7 -1
  202. package/dist/Helpers/TableExport.js +9 -3
  203. package/dist/Helpers/TableFilters.d.ts +1 -1
  204. package/dist/Helpers/TableFilters.js +24 -23
  205. package/dist/Helpers/TableHelpers.d.ts +38 -4
  206. package/dist/Helpers/TableHelpers.js +78 -10
  207. package/dist/Helpers/TableJoins.d.ts +13 -0
  208. package/dist/Helpers/TableJoins.js +34 -0
  209. package/dist/Helpers/TableStore.d.ts +28 -4
  210. package/dist/Helpers/TableStore.js +73 -11
  211. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  212. package/dist/Helpers/ToolbarFit.js +30 -0
  213. package/dist/Helpers/TreeStore.d.ts +156 -6
  214. package/dist/Helpers/TreeStore.js +547 -18
  215. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  216. package/dist/Interfaces/ApiInterfaces.js +10 -1
  217. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  218. package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
  219. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  220. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  221. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  222. package/dist/Interfaces/DomainInterfaces.js +1 -0
  223. package/dist/Interfaces/FileInterfaces.d.ts +183 -0
  224. package/dist/Interfaces/FileInterfaces.js +6 -0
  225. package/dist/Interfaces/FormInterfaces.d.ts +460 -32
  226. package/dist/Interfaces/FormInterfaces.js +79 -1
  227. package/dist/Interfaces/InputInterfaces.d.ts +22 -3
  228. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  229. package/dist/Interfaces/MailInterfaces.js +1 -0
  230. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  231. package/dist/Interfaces/RecordInterfaces.js +1 -0
  232. package/dist/Interfaces/TableInterfaces.d.ts +102 -3
  233. package/dist/Services/ExcelApi.js +5 -0
  234. package/dist/Services/FileApi.d.ts +13 -0
  235. package/dist/Services/FileApi.js +29 -0
  236. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  237. package/dist/Services/ImageEditorLoader.js +51 -0
  238. package/dist/Services/JoinSources.d.ts +79 -6
  239. package/dist/Services/JoinSources.js +132 -21
  240. package/dist/Services/MessageApi.d.ts +18 -0
  241. package/dist/Services/MessageApi.js +64 -0
  242. package/dist/Services/ModuleImport.d.ts +7 -0
  243. package/dist/Services/ModuleImport.js +22 -0
  244. package/dist/Shared.Index.d.ts +28 -0
  245. package/dist/Shared.Index.js +28 -0
  246. package/package.json +13 -2
  247. package/src/Styles/Nexus.Avatar.css +6 -5
  248. package/src/Styles/Nexus.Button.css +39 -4
  249. package/src/Styles/Nexus.Chat.css +114 -22
  250. package/src/Styles/Nexus.Choice.css +16 -0
  251. package/src/Styles/Nexus.Crud.css +486 -21
  252. package/src/Styles/Nexus.Dashboard.css +763 -0
  253. package/src/Styles/Nexus.Domain.css +384 -0
  254. package/src/Styles/Nexus.Dropdown.css +8 -0
  255. package/src/Styles/Nexus.Editor.css +20 -0
  256. package/src/Styles/Nexus.Files.css +437 -0
  257. package/src/Styles/Nexus.Form.css +5 -2
  258. package/src/Styles/Nexus.Index.css +7 -0
  259. package/src/Styles/Nexus.Input.css +73 -0
  260. package/src/Styles/Nexus.Mail.css +1062 -0
  261. package/src/Styles/Nexus.Menu.css +10 -1
  262. package/src/Styles/Nexus.Popup.css +135 -1
  263. package/src/Styles/Nexus.Print.css +288 -0
  264. package/src/Styles/Nexus.QrCode.css +12 -0
  265. package/src/Styles/Nexus.Record.css +430 -0
  266. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  267. package/src/Styles/Nexus.Table.css +1458 -1387
  268. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  269. package/src/Styles/Nexus.Themes.css +12 -6
  270. package/src/Styles/Nexus.Tokens.css +8 -3
  271. package/src/Styles/Nexus.Tree.css +41 -1
@@ -0,0 +1,700 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { iconArrowBackUp, iconEye, iconFile, iconFileText, iconPencil, iconPhoto, iconStar, iconTrash, iconUpload, iconX, InlineIcon } from "nexus-icons";
4
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { cx } from "../../Helpers/ClassHelpers.js";
6
+ import { formatDateTime } from "../../Helpers/DateTimeHelpers.js";
7
+ import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
8
+ import { useDatePreferences } from "../../Helpers/DatePreferences.js";
9
+ import { acceptOf, allowsFileChange, fileKindName, fileChangesNote, fileKindsSentence, fileRefusal, fileSizeText, narrowFileChanges, ownerFileActions, PENDING_REMOVAL_TAG, PICTURE_LIMITS, pictureLimits, pictureName, removalHeld, removalHeldQuestion, removalHeldSentence, roomLeft, takesDocuments, typedFile, } from "../../Helpers/FileAdapter.js";
10
+ import { readPath } from "../../Helpers/TableHelpers.js";
11
+ import { isPictureType } from "../../Interfaces/FileInterfaces.js";
12
+ import { getFileAdapter, primaryPictures } from "../../Services/FileApi.js";
13
+ import { loadImageEditor, preloadImageEditor } from "../../Services/ImageEditorLoader.js";
14
+ import { Button } from "../Documents/Button.js";
15
+ import { messageBox } from "../Layouts/MessageBox.js";
16
+ import { openPopup } from "../Layouts/PopupHost.js";
17
+ import { toast } from "../Layouts/Toaster.js";
18
+ /** What a person the backend lets only look at an owner's files is told, when it gave no words of its own. */
19
+ export const READ_ONLY_REASON = "You can see these files, but you may not change them.";
20
+ /** The longest side of a stored picture when neither the params nor the backend say: Drive's default (`PICTURE_LIMITS`). */
21
+ export const MAX_PICTURE_SIDE = PICTURE_LIMITS.maxSide;
22
+ /** Tells what a refused or failed call said, once, in the server's own words. */
23
+ export function toastFileFailure(title, answer) {
24
+ if (answer.aborted)
25
+ return;
26
+ toast(title, { tone: "danger", message: answer.message ?? "Try again in a moment." });
27
+ }
28
+ const ownerOf = (params) => ({ module: params.module, category: params.category, ownerId: params.ownerId });
29
+ /** Opens a file chooser for the category's kinds; resolves the chosen files, empty when nothing was chosen. */
30
+ function chooseFiles(accept, multiple) {
31
+ return new Promise(resolve => {
32
+ const input = document.createElement("input");
33
+ input.type = "file";
34
+ input.accept = accept;
35
+ input.multiple = multiple;
36
+ input.addEventListener("change", () => resolve(Array.from(input.files ?? [])), { once: true });
37
+ input.addEventListener("cancel", () => resolve([]), { once: true });
38
+ input.click();
39
+ });
40
+ }
41
+ /**
42
+ * The editor module, or null when it could not be downloaded: then a toast says why, and so does `onProblem` - the
43
+ * input's or the gallery's own note, which stays until a later try opens it (`loadImageEditor` keeps no failure, so the
44
+ * next Upload, Edit or View tries again).
45
+ */
46
+ async function editor(onProblem) {
47
+ try {
48
+ const module = await loadImageEditor();
49
+ onProblem(null);
50
+ return module;
51
+ }
52
+ catch (error) {
53
+ const message = error instanceof Error && error.message ? error.message : "The image editor could not be downloaded. Try again in a moment.";
54
+ onProblem(message);
55
+ toastFileFailure("The image editor could not be opened", { message });
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * What the editor is opened with for a picture of this category: the page's shape, kind and start quality within the
61
+ * category's limits (`pictureLimits`: Drive's settings, else the defaults).
62
+ */
63
+ function pictureOptions(params, category) {
64
+ const limits = pictureLimits(category);
65
+ return {
66
+ aspect: params.aspect ?? "free",
67
+ shape: params.shape,
68
+ maxWidth: Math.min(params.maxWidth ?? limits.maxSide, limits.maxSide),
69
+ maxHeight: Math.min(params.maxHeight ?? limits.maxSide, limits.maxSide),
70
+ type: params.type,
71
+ // Only kinds the category takes: a picture the editor would otherwise save as a WebP for its size is never refused.
72
+ types: category.types.filter(isPictureType),
73
+ quality: params.quality,
74
+ maxQuality: limits.quality / 100,
75
+ maxBytes: limits.maxBytes,
76
+ thumbnail: category.thumbnailSize || false,
77
+ };
78
+ }
79
+ /** What a toast says of a picture its size limit made smaller; nothing when it fitted as it was. */
80
+ function fittedNote(saved) {
81
+ return saved.fitted ? `Made smaller to fit the ${fileSizeText(saved.fitted.maxBytes)} a picture may take here.` : undefined;
82
+ }
83
+ /** One owner's files, and every change a person can make to them. `ownerId` null: nothing is read (a record not saved yet). */
84
+ export function useOwnerFiles(params) {
85
+ const [status, setStatus] = useState({ kind: "idle" });
86
+ const [busy, setBusy] = useState(null);
87
+ // Why the image editor could not be opened, the last time it was tried: the input says so until it opens.
88
+ const [editorProblem, setEditorProblem] = useState(null);
89
+ // The changes a refusal said are left for an owner (`FileAdapter.changesLeft`), with its words; another owner forgets it.
90
+ const [learned, setLearned] = useState(null);
91
+ const latest = useRef(params);
92
+ latest.current = params;
93
+ const { module, category, ownerId } = params;
94
+ const owner = ownerId ? { module, category, ownerId } : null;
95
+ const ownerKey = owner ? `${String(module)}|${category}|${ownerId}` : null;
96
+ const load = useCallback(async (signal) => {
97
+ const current = latest.current;
98
+ if (!current.ownerId)
99
+ return setStatus({ kind: "idle" });
100
+ const answer = await getFileAdapter().list(ownerOf(current), { signal });
101
+ if (signal?.aborted)
102
+ return;
103
+ if (!answer.ok || !answer.result)
104
+ return setStatus({ kind: "failed", message: answer.message ?? "The files could not be loaded." });
105
+ setStatus({ kind: "ready", data: answer.result });
106
+ primaryPictures.set(ownerOf(current), answer.result.files.find(file => file.isPrimary) ?? null);
107
+ return answer.result;
108
+ }, [ownerKey]);
109
+ useEffect(() => {
110
+ if (!ownerKey) {
111
+ setStatus({ kind: "idle" });
112
+ return;
113
+ }
114
+ setStatus({ kind: "loading" });
115
+ const controller = new AbortController();
116
+ void load(controller.signal);
117
+ return () => controller.abort();
118
+ }, [ownerKey, load]);
119
+ const data = status.kind === "ready" ? status.data : null;
120
+ const locked = params.lockReason?.trim() || null;
121
+ const canWrite = Boolean(data?.category.canWrite) && !params.readOnly && !locked;
122
+ /** Why nothing can be changed, when it is the page's or the backend's rule (not a form shown read only). */
123
+ const readOnlyReason = locked ?? (data && !data.category.canWrite ? data.readOnlyReason || READ_ONLY_REASON : null);
124
+ const pictures = data ? !takesDocuments(data.category) : true;
125
+ /** A Delete here waits for a review: the page said so, or the backend did. */
126
+ const holdsRemovals = Boolean(params.holdsRemovals || data?.category.holdsRemovals);
127
+ // The changes allowed: the page's, narrowed by what a refusal said of this owner ("only added to now").
128
+ const learnedNow = learned && learned.key === ownerKey ? learned : null;
129
+ const changes = narrowFileChanges(params.changes, learnedNow?.changes);
130
+ /** Why only some changes, while some are left out (`fileChangesNote`). */
131
+ const changesNote = canWrite ? fileChangesNote({ changes: params.changes, reason: params.changesReason }, learnedNow) : null;
132
+ /** The buttons offered for the owner's files, and for one file when given. */
133
+ const offer = (file) => ownerFileActions({ canWrite, changes, maxCount: data?.category.maxCount ?? 0, kept: data?.files.length ?? 0, holdsRemovals, keeps: typeof getFileAdapter().keep === "function" }, file);
134
+ /** "The picture stays until Nexus support approves its removal." for this file. */
135
+ const nounOf = (file) => (isPictureType(file.contentType) ? "picture" : "file");
136
+ const heldSentence = (file) => removalHeldSentence(nounOf(file), params.removalReviewer);
137
+ /** "If the public sees this picture, it stays until Nexus support approves its removal.": the answer decides. */
138
+ const heldQuestion = (file) => removalHeldQuestion(nounOf(file), params.removalReviewer);
139
+ /** A Delete held for a review: the file stays, marked, until the list is read again; the person is told once. */
140
+ function held(file, answered) {
141
+ const now = typeof answered === "object" && answered !== null && answered.id === file.id ? answered : null;
142
+ setStatus(current => current.kind !== "ready" ? current : { kind: "ready", data: { ...current.data, files: current.data.files.map(item => (item.id === file.id ? { ...item, ...now, pendingRemoval: true } : item)) } });
143
+ toast("Removal sent for review", { tone: "info", message: heldSentence(file) });
144
+ }
145
+ /**
146
+ * Says why a change failed, once; a refusal that says the files may now be changed only some ways (a receipt posted
147
+ * since this page read it) also narrows what is offered for this owner, under its words.
148
+ */
149
+ function refused(title, answer) {
150
+ toastFileFailure(title, answer);
151
+ const left = getFileAdapter().changesLeft(answer);
152
+ if (left && ownerKey)
153
+ setLearned({ key: ownerKey, changes: left, reason: answer.message ?? null });
154
+ }
155
+ // What the change under way did, for `onFilesChange` (the last, when it did several: an upload of three files).
156
+ const done = useRef(null);
157
+ const noted = (kind, file) => {
158
+ done.current = { kind, file };
159
+ };
160
+ /** Runs one change with its line of progress, then reads the files again and tells the page. */
161
+ async function change(label, work) {
162
+ if (busy)
163
+ return;
164
+ setBusy(label);
165
+ done.current = null;
166
+ const before = data?.files ?? [];
167
+ try {
168
+ const changed = await work();
169
+ if (changed) {
170
+ const read = await load();
171
+ if (read)
172
+ latest.current.onChange?.(read.files.find(file => file.isPrimary) ?? null);
173
+ if (done.current)
174
+ latest.current.onFilesChange?.(read ? read.files : before, done.current);
175
+ }
176
+ }
177
+ finally {
178
+ done.current = null;
179
+ setBusy(null);
180
+ }
181
+ }
182
+ /** Room for more, or a plain refusal. */
183
+ function hasRoom(files, wanted = 1) {
184
+ if (roomLeft(files) >= wanted)
185
+ return true;
186
+ toastFileFailure("No room for another file", { message: `This keeps at most ${files.category.maxCount}. Delete one first.` });
187
+ return false;
188
+ }
189
+ /**
190
+ * Opens the editor on a picture, then stores what it saved as a new picture; `primary` makes it the owner's picture.
191
+ * `replacing`: the file a one-file category keeps, deleted once the editor has saved, since the category has no room
192
+ * for both.
193
+ */
194
+ async function editAndStore(src, name, primary, replacing, edited = false) {
195
+ if (!owner || !data)
196
+ return false;
197
+ const module = await editor(setEditorProblem);
198
+ if (!module)
199
+ return false;
200
+ const current = latest.current;
201
+ const saved = await module.openImageEditor({ ...pictureOptions(current, data.category), src, title: current.title, fileName: name });
202
+ if (!saved)
203
+ return false;
204
+ const refusal = fileRefusal(data.category, saved.blob, saved.name || name);
205
+ if (refusal) {
206
+ toastFileFailure("The picture was not saved", { message: refusal });
207
+ return false;
208
+ }
209
+ if (replacing) {
210
+ const removed = await getFileAdapter().remove(owner, replacing.id);
211
+ if (!removed.ok) {
212
+ refused("The picture was not replaced", removed);
213
+ return false;
214
+ }
215
+ // Held for a review (a category not known to hold removals before): it keeps its place, so nothing is uploaded.
216
+ if (removalHeld(removed.result)) {
217
+ held(replacing, removed.result);
218
+ noted("hold", replacing);
219
+ return true;
220
+ }
221
+ noted("delete", replacing);
222
+ }
223
+ const stored = await getFileAdapter().upload(owner, { file: saved.blob, name: pictureName(saved.name || name, saved.blob.type), thumbnail: saved.thumbnail });
224
+ if (!stored.ok || !stored.result) {
225
+ toastFileFailure(replacing ? "The old picture was deleted, but the new one was not saved" : "The picture was not saved", stored);
226
+ // A replaced file is gone: the list is read again either way.
227
+ return Boolean(replacing);
228
+ }
229
+ noted(edited ? "edit" : "upload", stored.result);
230
+ // A picture may be added where the main one may not be chosen: it is kept, and stays as it is.
231
+ if (primary && !stored.result.isPrimary && allowsFileChange(changes, "primary")) {
232
+ const made = await getFileAdapter().setPrimary(owner, stored.result.id);
233
+ if (!made.ok) {
234
+ // Stored all the same: the list is read again, and the person is told once, not also "saved".
235
+ refused("The picture was saved, but it is not the main picture yet", made);
236
+ return true;
237
+ }
238
+ }
239
+ toast("Picture saved", { tone: "success", message: fittedNote(saved) });
240
+ return true;
241
+ }
242
+ /**
243
+ * Stores chosen files, up to the room left: each picture through the editor first, one at a time ("Picture 2 of 5" in
244
+ * its title), saved within the category's limits with its thumbnail - Cancel there skips that one, and asks whether to
245
+ * go on with the rest - and any other file (a PDF) as it is.
246
+ */
247
+ async function storeMany(chosen) {
248
+ if (!owner || !data)
249
+ return false;
250
+ const room = roomLeft(data);
251
+ // A file the browser gave no type is typed from its bytes: a picture among them still goes through the editor.
252
+ const files = await Promise.all(chosen.slice(0, room).map(typedFile));
253
+ if (files.length < chosen.length)
254
+ toastFileFailure("Some files were left out", { message: `Only ${room} more fit here; ${chosen.length - files.length} were not uploaded.` });
255
+ const pictureCount = files.filter(file => isPictureType(file.type)).length;
256
+ let module = null;
257
+ let stored = 0;
258
+ let fitted = 0;
259
+ let pictureAt = 0;
260
+ for (const [index, file] of files.entries()) {
261
+ setBusy(files.length > 1 ? `Uploading ${index + 1} of ${files.length}` : "Uploading");
262
+ const picture = isPictureType(file.type);
263
+ // A picture is checked for its kind now and for its size once the editor has made it (a 12 MB phone photo is well
264
+ // under the limit once made); any other file is sent as it is, so it is checked whole now.
265
+ const refusal = fileRefusal(data.category, picture ? { type: file.type, size: 0 } : file, file.name);
266
+ if (refusal) {
267
+ toastFileFailure("A file was not uploaded", { message: refusal });
268
+ if (picture)
269
+ pictureAt++;
270
+ continue;
271
+ }
272
+ let blob = file;
273
+ let thumbnail = null;
274
+ let name = file.name;
275
+ if (picture) {
276
+ pictureAt++;
277
+ module ??= await editor(setEditorProblem);
278
+ if (!module)
279
+ break;
280
+ const current = latest.current;
281
+ const step = pictureCount > 1 ? `Picture ${pictureAt} of ${pictureCount}` : null;
282
+ let saved = null;
283
+ try {
284
+ saved = await module.openImageEditor({ ...pictureOptions(current, data.category), src: file, title: step ? (current.title ? `${step} · ${current.title}` : step) : current.title, fileName: file.name });
285
+ }
286
+ catch {
287
+ toastFileFailure("A file was not uploaded", { message: `"${file.name}" could not be read as a picture.` });
288
+ continue;
289
+ }
290
+ if (!saved) {
291
+ // Cancelled: that picture is skipped; with more to come, the person says whether to go on.
292
+ const left = files.length - index - 1;
293
+ if (left === 0)
294
+ continue;
295
+ const goOn = await messageBox.confirm({
296
+ title: "Go on with the next file?",
297
+ message: `"${file.name}" was not uploaded. ${left} more ${left === 1 ? "file waits" : "files wait"} to be uploaded.`,
298
+ confirmLabel: "Next file",
299
+ cancelLabel: "Stop here",
300
+ });
301
+ if (!goOn)
302
+ break;
303
+ continue;
304
+ }
305
+ blob = saved.blob;
306
+ thumbnail = saved.thumbnail;
307
+ // An upload keeps the chosen file's name, with the kind it was saved as.
308
+ name = pictureName(file.name, saved.blob.type);
309
+ if (saved.fitted)
310
+ fitted++;
311
+ const tooLarge = fileRefusal(data.category, blob, file.name);
312
+ if (tooLarge) {
313
+ toastFileFailure("A file was not uploaded", { message: tooLarge });
314
+ continue;
315
+ }
316
+ }
317
+ const answer = await getFileAdapter().upload(owner, { file: blob, name, thumbnail });
318
+ if (answer.ok) {
319
+ stored++;
320
+ noted("upload", answer.result ?? null);
321
+ }
322
+ else
323
+ toastFileFailure(`"${file.name}" was not uploaded`, answer);
324
+ }
325
+ const limit = fileSizeText(pictureLimits(data.category).maxBytes);
326
+ const note = fitted === 0 ? undefined : fitted === 1 ? `One picture was made smaller to fit the ${limit} a picture may take here.` : `${fitted} pictures were made smaller to fit the ${limit} a picture may take here.`;
327
+ if (stored > 0)
328
+ toast(stored === 1 ? "File uploaded" : `${stored} files uploaded`, { tone: "success", message: note });
329
+ return stored > 0;
330
+ }
331
+ const actions = {
332
+ reload: () => load(),
333
+ /**
334
+ * Chooses a picture, edits it, and makes it the owner's picture (the picture input's Upload). In a category that
335
+ * keeps one file, the new picture takes the place of the one kept (Replace).
336
+ */
337
+ replace: async () => {
338
+ if (!data || !offer().upload)
339
+ return;
340
+ const replacing = data.category.maxCount === 1 ? data.files[0] : undefined;
341
+ if (!replacing && !hasRoom(data))
342
+ return;
343
+ const [file] = await chooseFiles(acceptOf({ types: data.category.types.filter(isPictureType) }), false);
344
+ if (file)
345
+ await change("Saving", () => editAndStore(file, file.name, true, replacing));
346
+ },
347
+ /** Chooses several files and stores them: each picture through the editor, one at a time (the gallery's Upload). */
348
+ upload: async () => {
349
+ if (!data || !offer().upload || !hasRoom(data))
350
+ return;
351
+ const chosen = await chooseFiles(acceptOf(data.category), data.category.maxCount > 1);
352
+ if (chosen.length > 0)
353
+ await change("Uploading", () => storeMany(chosen));
354
+ },
355
+ /**
356
+ * Edits a stored picture; what is saved is a new picture, the owner's picture when the edited one was. In a category
357
+ * that keeps one file, the edited picture takes its place.
358
+ */
359
+ edit: async (file) => {
360
+ if (!data || !offer(file).edit)
361
+ return;
362
+ const replacing = data.category.maxCount === 1 && data.files.length === 1 ? file : undefined;
363
+ if (!replacing && !hasRoom(data))
364
+ return;
365
+ await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing, true));
366
+ },
367
+ makePrimary: (file) => owner &&
368
+ (file ? offer(file).makePrimary : offer().clearPrimary) &&
369
+ change("Saving", async () => {
370
+ const answer = await getFileAdapter().setPrimary(owner, file?.id ?? null);
371
+ if (!answer.ok)
372
+ refused(file ? "This picture could not be made the main one" : "The picture could not be removed", answer);
373
+ else
374
+ noted("primary", file);
375
+ return answer.ok;
376
+ }),
377
+ rename: async (file) => {
378
+ if (!owner || !offer(file).rename)
379
+ return;
380
+ const name = await messageBox.prompt({ title: "Rename", label: "Name", defaultValue: file.name.replace(/\.[A-Za-z][A-Za-z0-9]{0,3}$/, ""), confirmLabel: "Rename" });
381
+ if (name === null || !name.trim())
382
+ return;
383
+ await change("Saving", async () => {
384
+ const answer = await getFileAdapter().rename(owner, file.id, name.trim());
385
+ if (!answer.ok)
386
+ refused("The file was not renamed", answer);
387
+ else
388
+ noted("rename", file);
389
+ return answer.ok;
390
+ });
391
+ },
392
+ remove: async (file) => {
393
+ if (!owner || !offer(file).remove)
394
+ return;
395
+ const yes = await messageBox.confirm({
396
+ title: `Delete "${file.name}"?`,
397
+ message: holdsRemovals
398
+ ? heldQuestion(file)
399
+ : file.isPrimary && (data?.files.length ?? 0) > 1
400
+ ? "It is the main picture now; the next one takes its place. It cannot be brought back."
401
+ : "It cannot be brought back.",
402
+ tone: holdsRemovals ? "warning" : "danger",
403
+ confirmLabel: "Delete",
404
+ });
405
+ if (!yes)
406
+ return;
407
+ await change(holdsRemovals ? "Sending the removal for review" : "Deleting", async () => {
408
+ const answer = await getFileAdapter().remove(owner, file.id);
409
+ if (!answer.ok)
410
+ refused("The file was not deleted", answer);
411
+ else if (removalHeld(answer.result)) {
412
+ held(file, answer.result);
413
+ noted("hold", file);
414
+ }
415
+ else
416
+ noted("delete", file);
417
+ return answer.ok;
418
+ });
419
+ },
420
+ /** Keeps a file whose removal waits for a review: the removal is cancelled. */
421
+ keep: async (file) => {
422
+ const adapter = getFileAdapter();
423
+ if (!owner || !adapter.keep || !offer(file).keep)
424
+ return;
425
+ await change("Keeping", async () => {
426
+ const answer = await adapter.keep(owner, file.id);
427
+ if (!answer.ok)
428
+ refused("The removal was not cancelled", answer);
429
+ else {
430
+ toast("Removal cancelled", { tone: "success", message: `"${file.name}" is kept.` });
431
+ noted("keep", answer.result ?? file);
432
+ }
433
+ return answer.ok;
434
+ });
435
+ },
436
+ /** Shows the pictures in the editor module's viewer, from `file`. */
437
+ view: async (file) => {
438
+ if (!data)
439
+ return;
440
+ const shown = data.files.filter(item => isPictureType(item.contentType));
441
+ const module = await editor(setEditorProblem);
442
+ if (!module)
443
+ return;
444
+ await module.openImageViewer({
445
+ images: shown.map(item => ({ src: getFileAdapter().fileUrl(item, "original"), title: item.name })),
446
+ index: Math.max(0, shown.indexOf(file)),
447
+ title: latest.current.title,
448
+ });
449
+ },
450
+ };
451
+ return { status, data, busy, canWrite, readOnlyReason, changes, changesNote, offer, pictures, holdsRemovals, editorProblem, actions };
452
+ }
453
+ const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
454
+ /**
455
+ * Keeps the keyboard focus in an owner's files through a change (`busy`). Its buttons stay focusable while it runs
456
+ * (`aria-disabled`, never `disabled`: a focused control switched off sends the focus to the page in every browser), and
457
+ * when it ends with the focused button gone - a deleted file's, Edit after Remove, a star once its picture is the main
458
+ * one - the focus goes to the first control left in the same tile, row or `role="group"` (the picture input's buttons),
459
+ * else the first in `root`. Only when the focus
460
+ * was in `root` as the change began (or on the page, after a confirmation, while `root` had it last), and only when it
461
+ * has dropped to the page since: a person who moved on keeps their place.
462
+ */
463
+ export function useFocusThroughChange(root, busy) {
464
+ const last = useRef(null);
465
+ const held = useRef(null);
466
+ useEffect(() => {
467
+ const box = root.current;
468
+ if (!box)
469
+ return;
470
+ const remember = (event) => {
471
+ if (event.target instanceof HTMLElement)
472
+ last.current = event.target;
473
+ };
474
+ box.addEventListener("focusin", remember);
475
+ return () => box.removeEventListener("focusin", remember);
476
+ }, [root]);
477
+ useLayoutEffect(() => {
478
+ const box = root.current;
479
+ if (!box)
480
+ return;
481
+ const now = document.activeElement;
482
+ const onPage = !now || now === document.body || now === document.documentElement || (now !== box && now.contains(box));
483
+ if (busy) {
484
+ if (held.current)
485
+ return;
486
+ const from = now instanceof HTMLElement && box.contains(now) ? now : onPage && last.current && box.contains(last.current) ? last.current : null;
487
+ held.current = from ? { element: from, group: from.closest("li, tr, [role=group]") } : null;
488
+ return;
489
+ }
490
+ const was = held.current;
491
+ held.current = null;
492
+ if (!was || !onPage)
493
+ return;
494
+ const usable = (element) => element instanceof HTMLElement && element.isConnected && box.contains(element) && !element.matches(":disabled") && element.getClientRects().length > 0;
495
+ const firstIn = (scope) => (scope?.isConnected ? Array.from(scope.querySelectorAll(FOCUSABLE)).find(usable) : undefined);
496
+ const target = (usable(was.element) ? was.element : undefined) ?? firstIn(was.group) ?? firstIn(box);
497
+ target?.focus({ preventScroll: true });
498
+ }, [root, busy]);
499
+ }
500
+ /** A control's props while a change runs: still focusable and announced as unavailable, its press ignored. */
501
+ export function busyProps(busy, run) {
502
+ return { "aria-disabled": busy ? true : undefined, onClick: () => (busy ? undefined : run()) };
503
+ }
504
+ /* The owner's picture, small */
505
+ /** The owner's picture as the page keeps it, read with the other rows' in one call. */
506
+ export function usePrimaryPicture(owner) {
507
+ const key = owner ? `${String(owner.module)}|${owner.category}|${owner.ownerId}` : "";
508
+ const subscribe = useCallback((listener) => (owner ? primaryPictures.subscribe(owner, listener) : () => undefined), [key]);
509
+ const read = () => (owner ? primaryPictures.peek(owner) : null);
510
+ const file = useSyncExternalStore(subscribe, read, () => undefined);
511
+ useEffect(() => {
512
+ if (owner && primaryPictures.peek(owner) === undefined)
513
+ void primaryPictures.get(owner);
514
+ }, [key]);
515
+ return file;
516
+ }
517
+ /** An owner's picture, small: its thumbnail, or, when it has none, the initials of its `name` or a picture icon. */
518
+ export function OwnerPicture({ owner, size = 28, aspect = 1, shape = "rect", label, name, onClick, className }) {
519
+ const file = usePrimaryPicture(owner);
520
+ const [failed, setFailed] = useState(null);
521
+ const url = file ? getFileAdapter().fileUrl(file, "thumbnail") : null;
522
+ const shown = url && failed !== url;
523
+ const ratio = typeof aspect === "number" && aspect > 0 ? aspect : 1;
524
+ const style = { "--nx-picture-h": typeof size === "number" ? `${size}px` : size, "--nx-picture-ratio": ratio };
525
+ const loading = file === undefined && Boolean(owner);
526
+ // Initials once it is known there is no picture (or it failed to load): never over a picture still on its way.
527
+ const letters = !shown && !loading && name ? initials(name) : "";
528
+ const face = shown ? _jsx("img", { src: url, alt: "", loading: "lazy", decoding: "async", onError: () => setFailed(url) }) : letters ? _jsx("span", { "aria-hidden": "true", children: letters }) : _jsx(InlineIcon, { icon: iconPhoto });
529
+ const classes = cx("nx-picture", shape === "circle" && "nx-picture--round", !shown && "nx-picture--empty", letters && "nx-picture--initials", loading && "nx-picture--loading", className);
530
+ const tone = letters ? avatarTone(name) : undefined;
531
+ if (!onClick) {
532
+ return (_jsx("span", { className: classes, style: style, "data-tone": tone, role: label ? "img" : undefined, "aria-label": label, title: label, children: face }));
533
+ }
534
+ return (_jsx("button", { type: "button", className: classes, style: style, "data-tone": tone, "aria-label": label, title: label, onClick: onClick, onPointerEnter: preloadImageEditor, children: face }));
535
+ }
536
+ /**
537
+ * "Removal waiting for review" on a file whose Delete waits for a review, a warning tag; when it was asked in the
538
+ * person's date-time format beside it where there is room (`withTime`), else in its tooltip.
539
+ */
540
+ function PendingRemoval({ file, when, withTime, className }) {
541
+ const asked = file.removalAt ? formatDateTime(file.removalAt, when) : "";
542
+ return (_jsx("span", { className: cx("nx-table__tag", className), "data-tone": "warning", title: asked ? `Removal asked ${asked}` : undefined, children: withTime && asked ? `${PENDING_REMOVAL_TAG} since ${asked}` : PENDING_REMOVAL_TAG }));
543
+ }
544
+ /** Keep, in place of Delete on a file whose removal waits: cancels the removal. */
545
+ function KeepButton({ file, busy, keep, size = "sm" }) {
546
+ return (_jsx(Button, { size: size, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), "aria-label": `Keep ${file.name}`, title: "Keep it: cancel its removal", ...busyProps(busy, () => void keep(file)), children: "Keep" }));
547
+ }
548
+ /** The held tiles a gallery draws while its pictures are read, so the grid is there at once. */
549
+ const HELD_TILES = [0, 1, 2];
550
+ /** "2 of 20 · PNG, JPEG or WebP, up to 300 KB each": how many are kept, of how many, what kinds and how large. */
551
+ function limitsLine(owned) {
552
+ // A picture's size is the category's picture limit (Drive's settings), a document's the category's own.
553
+ const pictureBytes = pictureLimits(owned.category).maxBytes;
554
+ const takesPictures = owned.category.types.some(isPictureType);
555
+ const sizes = takesPictures && takesDocuments(owned.category) ? `pictures up to ${fileSizeText(pictureBytes)}, documents up to ${fileSizeText(owned.category.maxBytes)}` : `up to ${fileSizeText(takesPictures ? pictureBytes : owned.category.maxBytes)} each`;
556
+ return `${owned.files.length} of ${owned.category.maxCount} · ${fileKindsSentence(owned.category.types)}, ${sizes}`;
557
+ }
558
+ /**
559
+ * Why a new file cannot be added: the category keeps no more. In the pictures' view a one-file category replaces, so it
560
+ * always can; the document list has no Replace, so there a full one-file category is held too.
561
+ */
562
+ function noRoomReason(owned, replaces = true) {
563
+ if (roomLeft(owned) > 0 || (replaces && owned.category.maxCount === 1))
564
+ return null;
565
+ return owned.category.maxCount === 1 ? "This keeps one file: delete it first." : `This keeps at most ${owned.category.maxCount}: delete one first.`;
566
+ }
567
+ /**
568
+ * An owner's pictures, or documents for a category that takes PDF: upload several up to the category's limit, make
569
+ * one the main picture, view them in the editor module's viewer, edit one (saved as a new picture), delete.
570
+ *
571
+ * Pictures come in two panes, as the 2026-06-20 gallery did: the main picture large, with Upload and Remove (no main
572
+ * picture; the pictures stay) under it and the count and kinds; beside it the pictures as a grid, each with its own
573
+ * buttons inside it - View, Make main, Edit, Delete (Make main and Delete not on the main one) - shown on hover, on a
574
+ * keyboard's focus in the picture, and always where nothing can hover (a touch screen). Under 560px of its own width
575
+ * the main picture sits small beside its buttons, over the grid.
576
+ */
577
+ export function PictureGallery(props) {
578
+ const files = useOwnerFiles(props);
579
+ const preferences = useDatePreferences();
580
+ const { status, data, busy, readOnlyReason, changesNote, offer, editorProblem, actions } = files;
581
+ const rootRef = useRef(null);
582
+ useFocusThroughChange(rootRef, busy);
583
+ // The main picture's original that could not be shown (a file gone since the list was read): the icon stands in.
584
+ const [coverFailed, setCoverFailed] = useState(null);
585
+ const documents = props.view ? props.view === "documents" : !files.pictures;
586
+ const noun = documents ? "file" : "picture";
587
+ const known = data ? (documents ? "documents" : "pictures") : null;
588
+ const onViewKnown = useRef(props.onViewKnown);
589
+ onViewKnown.current = props.onViewKnown;
590
+ useEffect(() => {
591
+ if (known)
592
+ onViewKnown.current?.(known);
593
+ }, [known]);
594
+ if (status.kind === "failed") {
595
+ return (_jsxs("div", { ref: rootRef, className: "nx-files", "data-state": "failed", children: [_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: status.message }), _jsx("div", { children: _jsx(Button, { size: "sm", onClick: () => void actions.reload(), children: "Try again" }) })] }));
596
+ }
597
+ const loading = status.kind === "loading" || status.kind === "idle";
598
+ const ratio = typeof props.aspect === "number" && props.aspect > 0 ? props.aspect : 1;
599
+ const shape = { "--nx-picture-ratio": ratio };
600
+ if (documents) {
601
+ if (loading) {
602
+ return (_jsx("div", { ref: rootRef, className: "nx-files", "data-state": "loading", "aria-busy": "true", children: _jsxs("p", { className: "nx-files__note", children: ["Loading the ", noun, "s\u2026"] }) }));
603
+ }
604
+ const owned = data;
605
+ // Held at the limit with its reason, as the pictures' Upload is; pressed, it says the refusal (`hasRoom`).
606
+ const full = noRoomReason(owned, false);
607
+ const offered = offer();
608
+ return (_jsxs("div", { ref: rootRef, className: "nx-files", "data-state": "ready", "aria-busy": busy ? "true" : undefined, children: [_jsxs("div", { className: "nx-files__bar", children: [_jsx("p", { className: "nx-files__note", "aria-live": "polite", children: busy ? `${busy}…` : limitsLine(owned) }), offered.upload && (_jsx(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || full ? true : undefined, title: full ?? undefined, onClick: () => (busy ? undefined : void actions.upload()), onPointerEnter: preloadImageEditor, children: "Upload" }))] }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem })), owned.files.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconFileText }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__list", children: owned.files.map(file => {
609
+ const mine = offer(file);
610
+ return (_jsxs("li", { className: "nx-files__row", children: [isPictureType(file.contentType) ? (_jsx("button", { type: "button", className: "nx-picture", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-label": `View ${file.name}`, onClick: () => void actions.view(file), children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) })) : (_jsx("span", { className: "nx-picture nx-picture--empty", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconFile }) })), _jsxs("div", { className: "nx-files__row-text", children: [_jsx("a", { className: "nx-files__name", href: getFileAdapter().fileUrl(file, "original"), target: "_blank", rel: "noopener", children: file.name }), _jsxs("span", { className: "nx-files__note", children: [fileKindName(file.contentType), " \u00B7 ", fileSizeText(file.size), file.createdAt && ` · ${formatDateTime(file.createdAt, preferences)}`] }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, withTime: true })] }), (mine.rename || mine.remove || mine.keep) && (_jsxs("div", { className: "nx-files__actions", children: [mine.rename && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Rename ${file.name}`, title: "Rename", ...busyProps(busy, () => void actions.rename(file)) }), mine.remove && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] }))] }, file.id));
611
+ }) }))] }));
612
+ }
613
+ const owned = loading ? null : data;
614
+ const kept = owned?.files ?? [];
615
+ const primary = kept.find(file => file.isPrimary) ?? null;
616
+ // A category that keeps one file: a new picture takes the place of the one kept, and Remove deletes it.
617
+ const single = owned?.category.maxCount === 1;
618
+ const noRoom = owned ? noRoomReason(owned) : null;
619
+ const coverUrl = primary ? getFileAdapter().fileUrl(primary, "original") : null;
620
+ const coverShown = coverUrl && coverFailed !== coverUrl;
621
+ const editHeld = busy || noRoom ? true : undefined;
622
+ const offered = owned ? offer() : null;
623
+ // Beside Upload: Delete in a one-file category (Keep while its removal waits), else Remove (no main picture, the pictures kept).
624
+ const mainOffer = primary ? offer(primary) : null;
625
+ const sideKeep = Boolean(single && mainOffer?.keep);
626
+ const sideRemove = Boolean(primary && offered && (single ? mainOffer?.remove : offered.clearPrimary));
627
+ return (_jsx("div", { ref: rootRef, className: "nx-files nx-files--panes", "data-state": loading ? "loading" : "ready", "aria-busy": loading || busy ? "true" : undefined, children: _jsxs("div", { className: "nx-files__frame", children: [_jsxs("div", { className: "nx-files__side", children: [_jsx("span", { className: cx("nx-picture", "nx-files__cover", !coverShown && "nx-picture--empty", loading && "nx-picture--loading"), style: shape, role: "img", "aria-label": primary ? `The main picture: ${primary.name}` : loading ? "The main picture" : "No main picture", children: coverShown ? _jsx("img", { src: coverUrl, alt: "", decoding: "async", onError: () => setCoverFailed(coverUrl) }) : _jsx(InlineIcon, { icon: iconPhoto }) }), _jsxs("div", { className: "nx-files__about", children: [offered && (offered.upload || sideRemove || sideKeep) && (_jsxs("div", { className: "nx-files__side-actions", children: [offered.upload && (_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || noRoom ? true : undefined, title: noRoom ?? undefined, onClick: () => (busy ? undefined : void (single && kept.length > 0 ? actions.replace() : actions.upload())), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: single && kept.length > 0 ? "Replace" : "Upload" })), sideRemove && primary && (_jsx(Button, { hoverTone: "danger", icon: _jsx(InlineIcon, { icon: single ? iconTrash : iconX }), title: single ? undefined : "Show no picture: the pictures are kept", ...busyProps(busy, () => void (single ? actions.remove(primary) : actions.makePrimary(null))), children: single ? "Delete" : "Remove" })), sideKeep && primary && _jsx(KeepButton, { file: primary, busy: busy, keep: actions.keep, size: "md" })] })), primary?.pendingRemoval && _jsx(PendingRemoval, { file: primary, when: preferences, withTime: true }), _jsx("p", { className: "nx-files__note", "aria-live": "polite", children: loading ? `Loading the ${noun}s…` : busy ? `${busy}…` : limitsLine(owned) }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem }))] })] }), _jsx("div", { className: "nx-files__pane", children: loading ? (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-hidden": "true", children: HELD_TILES.map(index => (_jsx("li", { children: _jsx("span", { className: "nx-table__bone nx-files__bone" }) }, index))) })) : kept.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconPhoto }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered?.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-label": props.title ? `${props.title}: pictures` : "Pictures", children: kept.map(file => {
628
+ const mine = offer(file);
629
+ const about = [file.name, file.isPrimary && "the main picture", file.pendingRemoval && PENDING_REMOVAL_TAG.toLowerCase()].filter(Boolean).join(", ");
630
+ return (_jsxs("li", { className: "nx-files__tile", "data-primary": file.isPrimary || undefined, "data-pending": file.pendingRemoval || undefined, children: [_jsxs("span", { className: "nx-files__thumb", children: [_jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, className: "nx-files__pending" })] }), file.isPrimary && (_jsx("span", { className: "nx-table__tag nx-files__mark", "data-tone": "primary", children: "Main" })), _jsxs("div", { className: "nx-files__tools", role: "group", "aria-label": about, children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconEye }), "aria-label": `View ${file.name}`, title: "View", onClick: () => void actions.view(file), onPointerEnter: preloadImageEditor }), mine.makePrimary && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconStar }), "aria-label": `Make ${file.name} the main picture`, title: "Make it the main picture", ...busyProps(busy, () => void actions.makePrimary(file)) }), mine.edit && (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Edit ${file.name}`, title: noRoom ? `Edit. ${noRoom}` : "Edit: saved as a new picture", "aria-disabled": editHeld, onClick: () => (busy ? undefined : void actions.edit(file)), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor })), mine.remove && !file.isPrimary && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] })] }, file.id));
631
+ }) })) })] }) }));
632
+ }
633
+ /** An owner's documents as a list: `PictureGallery` with `view="documents"`. */
634
+ export function DocumentList(props) {
635
+ return _jsx(PictureGallery, { ...props, view: "documents" });
636
+ }
637
+ /**
638
+ * Opens an owner's pictures (or documents) in a window; one window per owner and category. Pictures fill a wide
639
+ * window edge to edge in their two panes, the grid scrolling; documents are a list in a narrower one. Without `view`,
640
+ * the title, icon and size follow what the category takes once its files are read: a category that takes PDF says
641
+ * "Documents".
642
+ */
643
+ export function openPictureGallery({ popupTitle, ...props }) {
644
+ const heading = (view) => {
645
+ const what = view === "documents" ? "Documents" : "Pictures";
646
+ return {
647
+ title: popupTitle ?? (props.title ? `${what} · ${props.title}` : what),
648
+ icon: _jsx(InlineIcon, { icon: view === "documents" ? iconFileText : iconPhoto }),
649
+ size: view === "documents" ? "lg" : "xl",
650
+ flush: view !== "documents",
651
+ };
652
+ };
653
+ let shown = props.view ?? "pictures";
654
+ return openPopup({
655
+ key: `nx-files:${String(props.module)}:${props.category}:${props.ownerId}`,
656
+ ...heading(shown),
657
+ content: popup => (_jsx(PictureGallery, { ...props, onViewKnown: view => {
658
+ props.onViewKnown?.(view);
659
+ if (view === shown)
660
+ return;
661
+ shown = view;
662
+ popup.update(heading(view));
663
+ } })),
664
+ });
665
+ }
666
+ /**
667
+ * A column with each row's picture: every visible row's read in one call per page, and a click opens the row's
668
+ * gallery. A change there redraws the row's picture without a reload.
669
+ * It is a `lead` column: every table draws it first of the page's columns (after the row number), pinned on the left
670
+ * when the page pins others there, wherever it stands in `columns`, until the user moves, unpins or hides it.
671
+ *
672
+ * @example
673
+ * columns: [pictureColumn({ module: NexusModule.Enterprise, category: "branch-picture", rowTitle: row => row.branchName }), ...]
674
+ */
675
+ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, view, ...column }) {
676
+ return {
677
+ key: "picture",
678
+ label: "Picture",
679
+ width: "4.5rem",
680
+ align: "center",
681
+ sortable: false,
682
+ searchable: false,
683
+ resizable: false,
684
+ lead: true,
685
+ ...column,
686
+ value: () => null,
687
+ render: row => {
688
+ const id = readPath(row, ownerKey);
689
+ const ownerId = typeof id === "string" || typeof id === "number" ? String(id) : null;
690
+ if (!ownerId)
691
+ return null;
692
+ const title = rowTitle?.(row);
693
+ const owner = { module, category, ownerId };
694
+ const named = nameKey ? readPath(row, nameKey) : null;
695
+ const locked = typeof lockReason === "function" ? lockReason(row) : lockReason;
696
+ return (_jsx(OwnerPicture, { owner: owner, aspect: aspect, shape: shape, name: typeof named === "string" ? named : null, label: title ? `${title}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...owner, title, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
697
+ },
698
+ text: () => "",
699
+ };
700
+ }