nexus-shared 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1564 -0
- package/README.md +1705 -1123
- package/dist/Client.Index.d.ts +14 -0
- package/dist/Client.Index.js +14 -0
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +576 -243
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +55 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +123 -17
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +195 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- package/dist/Components/Inputs/Dropdown.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +5 -5
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +37 -32
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +11 -2
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TreeView.d.ts +53 -2
- package/dist/Components/Inputs/TreeView.js +147 -13
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Viewers/DataTable.js +150 -48
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +21 -5
- package/dist/Components/Viewers/DataTableParts.js +26 -14
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- package/dist/Helpers/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CrudBackend.d.ts +20 -2
- package/dist/Helpers/CrudBackend.js +46 -2
- package/dist/Helpers/CrudColumns.d.ts +53 -6
- package/dist/Helpers/CrudColumns.js +98 -7
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +81 -2
- package/dist/Helpers/CrudHelpers.js +195 -0
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +78 -0
- package/dist/Helpers/FileAdapter.js +102 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +157 -74
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +14 -2
- package/dist/Helpers/RequestCache.js +22 -3
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +9 -3
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +23 -2
- package/dist/Helpers/TableHelpers.js +51 -10
- package/dist/Helpers/TableJoins.d.ts +13 -0
- package/dist/Helpers/TableJoins.js +34 -0
- package/dist/Helpers/TableStore.d.ts +21 -1
- package/dist/Helpers/TableStore.js +64 -7
- package/dist/Helpers/TreeStore.d.ts +81 -0
- package/dist/Helpers/TreeStore.js +365 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +465 -5
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +391 -30
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +15 -3
- package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +74 -1
- package/dist/Services/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +37 -5
- package/dist/Services/JoinSources.js +90 -14
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Shared.Index.d.ts +16 -0
- package/dist/Shared.Index.js +16 -0
- package/package.json +7 -2
- package/src/Styles/Nexus.Avatar.css +6 -5
- package/src/Styles/Nexus.Button.css +10 -4
- package/src/Styles/Nexus.Choice.css +16 -0
- package/src/Styles/Nexus.Crud.css +383 -9
- package/src/Styles/Nexus.Dropdown.css +8 -0
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +5 -2
- package/src/Styles/Nexus.Index.css +4 -0
- package/src/Styles/Nexus.Input.css +42 -0
- package/src/Styles/Nexus.Menu.css +10 -1
- package/src/Styles/Nexus.Popup.css +77 -1
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Tab.Buttons.css +87 -22
- package/src/Styles/Nexus.Table.css +67 -5
- package/src/Styles/Nexus.Themes.css +12 -6
- package/src/Styles/Nexus.Tokens.css +8 -3
- package/src/Styles/Nexus.Tree.css +31 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { 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, fileKindName, fileKindsSentence, fileRefusal, fileSizeText, PICTURE_LIMITS, pictureLimits, pictureName, 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
|
+
const latest = useRef(params);
|
|
90
|
+
latest.current = params;
|
|
91
|
+
const { module, category, ownerId } = params;
|
|
92
|
+
const owner = ownerId ? { module, category, ownerId } : null;
|
|
93
|
+
const ownerKey = owner ? `${String(module)}|${category}|${ownerId}` : null;
|
|
94
|
+
const load = useCallback(async (signal) => {
|
|
95
|
+
const current = latest.current;
|
|
96
|
+
if (!current.ownerId)
|
|
97
|
+
return setStatus({ kind: "idle" });
|
|
98
|
+
const answer = await getFileAdapter().list(ownerOf(current), { signal });
|
|
99
|
+
if (signal?.aborted)
|
|
100
|
+
return;
|
|
101
|
+
if (!answer.ok || !answer.result)
|
|
102
|
+
return setStatus({ kind: "failed", message: answer.message ?? "The files could not be loaded." });
|
|
103
|
+
setStatus({ kind: "ready", data: answer.result });
|
|
104
|
+
const primary = answer.result.files.find(file => file.isPrimary) ?? null;
|
|
105
|
+
primaryPictures.set(ownerOf(current), primary);
|
|
106
|
+
return primary;
|
|
107
|
+
}, [ownerKey]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!ownerKey) {
|
|
110
|
+
setStatus({ kind: "idle" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setStatus({ kind: "loading" });
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
void load(controller.signal);
|
|
116
|
+
return () => controller.abort();
|
|
117
|
+
}, [ownerKey, load]);
|
|
118
|
+
const data = status.kind === "ready" ? status.data : null;
|
|
119
|
+
const locked = params.lockReason?.trim() || null;
|
|
120
|
+
const canWrite = Boolean(data?.category.canWrite) && !params.readOnly && !locked;
|
|
121
|
+
/** Why nothing can be changed, when it is the page's or the backend's rule (not a form shown read only). */
|
|
122
|
+
const readOnlyReason = locked ?? (data && !data.category.canWrite ? data.readOnlyReason || READ_ONLY_REASON : null);
|
|
123
|
+
const pictures = data ? !takesDocuments(data.category) : true;
|
|
124
|
+
/** Runs one change with its line of progress, then reads the files again and tells the page. */
|
|
125
|
+
async function change(label, work) {
|
|
126
|
+
if (busy)
|
|
127
|
+
return;
|
|
128
|
+
setBusy(label);
|
|
129
|
+
try {
|
|
130
|
+
const changed = await work();
|
|
131
|
+
if (changed) {
|
|
132
|
+
const primary = await load();
|
|
133
|
+
if (primary !== undefined)
|
|
134
|
+
latest.current.onChange?.(primary);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
setBusy(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Room for more, or a plain refusal. */
|
|
142
|
+
function hasRoom(files, wanted = 1) {
|
|
143
|
+
if (roomLeft(files) >= wanted)
|
|
144
|
+
return true;
|
|
145
|
+
toastFileFailure("No room for another file", { message: `This keeps at most ${files.category.maxCount}. Delete one first.` });
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Opens the editor on a picture, then stores what it saved as a new picture; `primary` makes it the owner's picture.
|
|
150
|
+
* `replacing`: the file a one-file category keeps, deleted once the editor has saved, since the category has no room
|
|
151
|
+
* for both.
|
|
152
|
+
*/
|
|
153
|
+
async function editAndStore(src, name, primary, replacing) {
|
|
154
|
+
if (!owner || !data)
|
|
155
|
+
return false;
|
|
156
|
+
const module = await editor(setEditorProblem);
|
|
157
|
+
if (!module)
|
|
158
|
+
return false;
|
|
159
|
+
const current = latest.current;
|
|
160
|
+
const saved = await module.openImageEditor({ ...pictureOptions(current, data.category), src, title: current.title, fileName: name });
|
|
161
|
+
if (!saved)
|
|
162
|
+
return false;
|
|
163
|
+
const refusal = fileRefusal(data.category, saved.blob, saved.name || name);
|
|
164
|
+
if (refusal) {
|
|
165
|
+
toastFileFailure("The picture was not saved", { message: refusal });
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (replacing) {
|
|
169
|
+
const removed = await getFileAdapter().remove(owner, replacing.id);
|
|
170
|
+
if (!removed.ok) {
|
|
171
|
+
toastFileFailure("The picture was not replaced", removed);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const stored = await getFileAdapter().upload(owner, { file: saved.blob, name: pictureName(saved.name || name, saved.blob.type), thumbnail: saved.thumbnail });
|
|
176
|
+
if (!stored.ok || !stored.result) {
|
|
177
|
+
toastFileFailure(replacing ? "The old picture was deleted, but the new one was not saved" : "The picture was not saved", stored);
|
|
178
|
+
// A replaced file is gone: the list is read again either way.
|
|
179
|
+
return Boolean(replacing);
|
|
180
|
+
}
|
|
181
|
+
if (primary && !stored.result.isPrimary) {
|
|
182
|
+
const made = await getFileAdapter().setPrimary(owner, stored.result.id);
|
|
183
|
+
if (!made.ok) {
|
|
184
|
+
// Stored all the same: the list is read again, and the person is told once, not also "saved".
|
|
185
|
+
toastFileFailure("The picture was saved, but it is not the main picture yet", made);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
toast("Picture saved", { tone: "success", message: fittedNote(saved) });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Stores chosen files, up to the room left: each picture through the editor first, one at a time ("Picture 2 of 5" in
|
|
194
|
+
* its title), saved within the category's limits with its thumbnail - Cancel there skips that one, and asks whether to
|
|
195
|
+
* go on with the rest - and any other file (a PDF) as it is.
|
|
196
|
+
*/
|
|
197
|
+
async function storeMany(chosen) {
|
|
198
|
+
if (!owner || !data)
|
|
199
|
+
return false;
|
|
200
|
+
const room = roomLeft(data);
|
|
201
|
+
// A file the browser gave no type is typed from its bytes: a picture among them still goes through the editor.
|
|
202
|
+
const files = await Promise.all(chosen.slice(0, room).map(typedFile));
|
|
203
|
+
if (files.length < chosen.length)
|
|
204
|
+
toastFileFailure("Some files were left out", { message: `Only ${room} more fit here; ${chosen.length - files.length} were not uploaded.` });
|
|
205
|
+
const pictureCount = files.filter(file => isPictureType(file.type)).length;
|
|
206
|
+
let module = null;
|
|
207
|
+
let stored = 0;
|
|
208
|
+
let fitted = 0;
|
|
209
|
+
let pictureAt = 0;
|
|
210
|
+
for (const [index, file] of files.entries()) {
|
|
211
|
+
setBusy(files.length > 1 ? `Uploading ${index + 1} of ${files.length}` : "Uploading");
|
|
212
|
+
const picture = isPictureType(file.type);
|
|
213
|
+
// 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
|
|
214
|
+
// under the limit once made); any other file is sent as it is, so it is checked whole now.
|
|
215
|
+
const refusal = fileRefusal(data.category, picture ? { type: file.type, size: 0 } : file, file.name);
|
|
216
|
+
if (refusal) {
|
|
217
|
+
toastFileFailure("A file was not uploaded", { message: refusal });
|
|
218
|
+
if (picture)
|
|
219
|
+
pictureAt++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let blob = file;
|
|
223
|
+
let thumbnail = null;
|
|
224
|
+
let name = file.name;
|
|
225
|
+
if (picture) {
|
|
226
|
+
pictureAt++;
|
|
227
|
+
module ??= await editor(setEditorProblem);
|
|
228
|
+
if (!module)
|
|
229
|
+
break;
|
|
230
|
+
const current = latest.current;
|
|
231
|
+
const step = pictureCount > 1 ? `Picture ${pictureAt} of ${pictureCount}` : null;
|
|
232
|
+
let saved = null;
|
|
233
|
+
try {
|
|
234
|
+
saved = await module.openImageEditor({ ...pictureOptions(current, data.category), src: file, title: step ? (current.title ? `${step} · ${current.title}` : step) : current.title, fileName: file.name });
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
toastFileFailure("A file was not uploaded", { message: `"${file.name}" could not be read as a picture.` });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!saved) {
|
|
241
|
+
// Cancelled: that picture is skipped; with more to come, the person says whether to go on.
|
|
242
|
+
const left = files.length - index - 1;
|
|
243
|
+
if (left === 0)
|
|
244
|
+
continue;
|
|
245
|
+
const goOn = await messageBox.confirm({
|
|
246
|
+
title: "Go on with the next file?",
|
|
247
|
+
message: `"${file.name}" was not uploaded. ${left} more ${left === 1 ? "file waits" : "files wait"} to be uploaded.`,
|
|
248
|
+
confirmLabel: "Next file",
|
|
249
|
+
cancelLabel: "Stop here",
|
|
250
|
+
});
|
|
251
|
+
if (!goOn)
|
|
252
|
+
break;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
blob = saved.blob;
|
|
256
|
+
thumbnail = saved.thumbnail;
|
|
257
|
+
// An upload keeps the chosen file's name, with the kind it was saved as.
|
|
258
|
+
name = pictureName(file.name, saved.blob.type);
|
|
259
|
+
if (saved.fitted)
|
|
260
|
+
fitted++;
|
|
261
|
+
const tooLarge = fileRefusal(data.category, blob, file.name);
|
|
262
|
+
if (tooLarge) {
|
|
263
|
+
toastFileFailure("A file was not uploaded", { message: tooLarge });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const answer = await getFileAdapter().upload(owner, { file: blob, name, thumbnail });
|
|
268
|
+
if (answer.ok)
|
|
269
|
+
stored++;
|
|
270
|
+
else
|
|
271
|
+
toastFileFailure(`"${file.name}" was not uploaded`, answer);
|
|
272
|
+
}
|
|
273
|
+
const limit = fileSizeText(pictureLimits(data.category).maxBytes);
|
|
274
|
+
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.`;
|
|
275
|
+
if (stored > 0)
|
|
276
|
+
toast(stored === 1 ? "File uploaded" : `${stored} files uploaded`, { tone: "success", message: note });
|
|
277
|
+
return stored > 0;
|
|
278
|
+
}
|
|
279
|
+
const actions = {
|
|
280
|
+
reload: () => load(),
|
|
281
|
+
/**
|
|
282
|
+
* Chooses a picture, edits it, and makes it the owner's picture (the picture input's Upload). In a category that
|
|
283
|
+
* keeps one file, the new picture takes the place of the one kept (Replace).
|
|
284
|
+
*/
|
|
285
|
+
replace: async () => {
|
|
286
|
+
if (!data)
|
|
287
|
+
return;
|
|
288
|
+
const replacing = data.category.maxCount === 1 ? data.files[0] : undefined;
|
|
289
|
+
if (!replacing && !hasRoom(data))
|
|
290
|
+
return;
|
|
291
|
+
const [file] = await chooseFiles(acceptOf({ types: data.category.types.filter(isPictureType) }), false);
|
|
292
|
+
if (file)
|
|
293
|
+
await change("Saving", () => editAndStore(file, file.name, true, replacing));
|
|
294
|
+
},
|
|
295
|
+
/** Chooses several files and stores them: each picture through the editor, one at a time (the gallery's Upload). */
|
|
296
|
+
upload: async () => {
|
|
297
|
+
if (!data || !hasRoom(data))
|
|
298
|
+
return;
|
|
299
|
+
const chosen = await chooseFiles(acceptOf(data.category), data.category.maxCount > 1);
|
|
300
|
+
if (chosen.length > 0)
|
|
301
|
+
await change("Uploading", () => storeMany(chosen));
|
|
302
|
+
},
|
|
303
|
+
/**
|
|
304
|
+
* Edits a stored picture; what is saved is a new picture, the owner's picture when the edited one was. In a category
|
|
305
|
+
* that keeps one file, the edited picture takes its place.
|
|
306
|
+
*/
|
|
307
|
+
edit: async (file) => {
|
|
308
|
+
if (!data)
|
|
309
|
+
return;
|
|
310
|
+
const replacing = data.category.maxCount === 1 && data.files.length === 1 ? file : undefined;
|
|
311
|
+
if (!replacing && !hasRoom(data))
|
|
312
|
+
return;
|
|
313
|
+
await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing));
|
|
314
|
+
},
|
|
315
|
+
makePrimary: (file) => owner &&
|
|
316
|
+
change("Saving", async () => {
|
|
317
|
+
const answer = await getFileAdapter().setPrimary(owner, file?.id ?? null);
|
|
318
|
+
if (!answer.ok)
|
|
319
|
+
toastFileFailure(file ? "This picture could not be made the main one" : "The picture could not be removed", answer);
|
|
320
|
+
return answer.ok;
|
|
321
|
+
}),
|
|
322
|
+
rename: async (file) => {
|
|
323
|
+
if (!owner)
|
|
324
|
+
return;
|
|
325
|
+
const name = await messageBox.prompt({ title: "Rename", label: "Name", defaultValue: file.name.replace(/\.[A-Za-z][A-Za-z0-9]{0,3}$/, ""), confirmLabel: "Rename" });
|
|
326
|
+
if (name === null || !name.trim())
|
|
327
|
+
return;
|
|
328
|
+
await change("Saving", async () => {
|
|
329
|
+
const answer = await getFileAdapter().rename(owner, file.id, name.trim());
|
|
330
|
+
if (!answer.ok)
|
|
331
|
+
toastFileFailure("The file was not renamed", answer);
|
|
332
|
+
return answer.ok;
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
remove: async (file) => {
|
|
336
|
+
if (!owner)
|
|
337
|
+
return;
|
|
338
|
+
const yes = await messageBox.confirm({
|
|
339
|
+
title: `Delete "${file.name}"?`,
|
|
340
|
+
message: file.isPrimary && (data?.files.length ?? 0) > 1 ? "It is the main picture now; the next one takes its place. It cannot be brought back." : "It cannot be brought back.",
|
|
341
|
+
tone: "danger",
|
|
342
|
+
confirmLabel: "Delete",
|
|
343
|
+
});
|
|
344
|
+
if (!yes)
|
|
345
|
+
return;
|
|
346
|
+
await change("Deleting", async () => {
|
|
347
|
+
const answer = await getFileAdapter().remove(owner, file.id);
|
|
348
|
+
if (!answer.ok)
|
|
349
|
+
toastFileFailure("The file was not deleted", answer);
|
|
350
|
+
return answer.ok;
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
/** Shows the pictures in the editor module's viewer, from `file`. */
|
|
354
|
+
view: async (file) => {
|
|
355
|
+
if (!data)
|
|
356
|
+
return;
|
|
357
|
+
const shown = data.files.filter(item => isPictureType(item.contentType));
|
|
358
|
+
const module = await editor(setEditorProblem);
|
|
359
|
+
if (!module)
|
|
360
|
+
return;
|
|
361
|
+
await module.openImageViewer({
|
|
362
|
+
images: shown.map(item => ({ src: getFileAdapter().fileUrl(item, "original"), title: item.name })),
|
|
363
|
+
index: Math.max(0, shown.indexOf(file)),
|
|
364
|
+
title: latest.current.title,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
return { status, data, busy, canWrite, readOnlyReason, pictures, editorProblem, actions };
|
|
369
|
+
}
|
|
370
|
+
const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
371
|
+
/**
|
|
372
|
+
* Keeps the keyboard focus in an owner's files through a change (`busy`). Its buttons stay focusable while it runs
|
|
373
|
+
* (`aria-disabled`, never `disabled`: a focused control switched off sends the focus to the page in every browser), and
|
|
374
|
+
* when it ends with the focused button gone - a deleted file's, Edit after Remove, a star once its picture is the main
|
|
375
|
+
* one - the focus goes to the first control left in the same tile, row or `role="group"` (the picture input's buttons),
|
|
376
|
+
* else the first in `root`. Only when the focus
|
|
377
|
+
* was in `root` as the change began (or on the page, after a confirmation, while `root` had it last), and only when it
|
|
378
|
+
* has dropped to the page since: a person who moved on keeps their place.
|
|
379
|
+
*/
|
|
380
|
+
export function useFocusThroughChange(root, busy) {
|
|
381
|
+
const last = useRef(null);
|
|
382
|
+
const held = useRef(null);
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
const box = root.current;
|
|
385
|
+
if (!box)
|
|
386
|
+
return;
|
|
387
|
+
const remember = (event) => {
|
|
388
|
+
if (event.target instanceof HTMLElement)
|
|
389
|
+
last.current = event.target;
|
|
390
|
+
};
|
|
391
|
+
box.addEventListener("focusin", remember);
|
|
392
|
+
return () => box.removeEventListener("focusin", remember);
|
|
393
|
+
}, [root]);
|
|
394
|
+
useLayoutEffect(() => {
|
|
395
|
+
const box = root.current;
|
|
396
|
+
if (!box)
|
|
397
|
+
return;
|
|
398
|
+
const now = document.activeElement;
|
|
399
|
+
const onPage = !now || now === document.body || now === document.documentElement || (now !== box && now.contains(box));
|
|
400
|
+
if (busy) {
|
|
401
|
+
if (held.current)
|
|
402
|
+
return;
|
|
403
|
+
const from = now instanceof HTMLElement && box.contains(now) ? now : onPage && last.current && box.contains(last.current) ? last.current : null;
|
|
404
|
+
held.current = from ? { element: from, group: from.closest("li, tr, [role=group]") } : null;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const was = held.current;
|
|
408
|
+
held.current = null;
|
|
409
|
+
if (!was || !onPage)
|
|
410
|
+
return;
|
|
411
|
+
const usable = (element) => element instanceof HTMLElement && element.isConnected && box.contains(element) && !element.matches(":disabled") && element.getClientRects().length > 0;
|
|
412
|
+
const firstIn = (scope) => (scope?.isConnected ? Array.from(scope.querySelectorAll(FOCUSABLE)).find(usable) : undefined);
|
|
413
|
+
const target = (usable(was.element) ? was.element : undefined) ?? firstIn(was.group) ?? firstIn(box);
|
|
414
|
+
target?.focus({ preventScroll: true });
|
|
415
|
+
}, [root, busy]);
|
|
416
|
+
}
|
|
417
|
+
/** A control's props while a change runs: still focusable and announced as unavailable, its press ignored. */
|
|
418
|
+
export function busyProps(busy, run) {
|
|
419
|
+
return { "aria-disabled": busy ? true : undefined, onClick: () => (busy ? undefined : run()) };
|
|
420
|
+
}
|
|
421
|
+
/* The owner's picture, small */
|
|
422
|
+
/** The owner's picture as the page keeps it, read with the other rows' in one call. */
|
|
423
|
+
export function usePrimaryPicture(owner) {
|
|
424
|
+
const key = owner ? `${String(owner.module)}|${owner.category}|${owner.ownerId}` : "";
|
|
425
|
+
const subscribe = useCallback((listener) => (owner ? primaryPictures.subscribe(owner, listener) : () => undefined), [key]);
|
|
426
|
+
const read = () => (owner ? primaryPictures.peek(owner) : null);
|
|
427
|
+
const file = useSyncExternalStore(subscribe, read, () => undefined);
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
if (owner && primaryPictures.peek(owner) === undefined)
|
|
430
|
+
void primaryPictures.get(owner);
|
|
431
|
+
}, [key]);
|
|
432
|
+
return file;
|
|
433
|
+
}
|
|
434
|
+
/** An owner's picture, small: its thumbnail, or, when it has none, the initials of its `name` or a picture icon. */
|
|
435
|
+
export function OwnerPicture({ owner, size = 28, aspect = 1, shape = "rect", label, name, onClick, className }) {
|
|
436
|
+
const file = usePrimaryPicture(owner);
|
|
437
|
+
const [failed, setFailed] = useState(null);
|
|
438
|
+
const url = file ? getFileAdapter().fileUrl(file, "thumbnail") : null;
|
|
439
|
+
const shown = url && failed !== url;
|
|
440
|
+
const ratio = typeof aspect === "number" && aspect > 0 ? aspect : 1;
|
|
441
|
+
const style = { "--nx-picture-h": typeof size === "number" ? `${size}px` : size, "--nx-picture-ratio": ratio };
|
|
442
|
+
const loading = file === undefined && Boolean(owner);
|
|
443
|
+
// Initials once it is known there is no picture (or it failed to load): never over a picture still on its way.
|
|
444
|
+
const letters = !shown && !loading && name ? initials(name) : "";
|
|
445
|
+
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 });
|
|
446
|
+
const classes = cx("nx-picture", shape === "circle" && "nx-picture--round", !shown && "nx-picture--empty", letters && "nx-picture--initials", loading && "nx-picture--loading", className);
|
|
447
|
+
const tone = letters ? avatarTone(name) : undefined;
|
|
448
|
+
if (!onClick) {
|
|
449
|
+
return (_jsx("span", { className: classes, style: style, "data-tone": tone, role: label ? "img" : undefined, "aria-label": label, title: label, children: face }));
|
|
450
|
+
}
|
|
451
|
+
return (_jsx("button", { type: "button", className: classes, style: style, "data-tone": tone, "aria-label": label, title: label, onClick: onClick, onPointerEnter: preloadImageEditor, children: face }));
|
|
452
|
+
}
|
|
453
|
+
/** The held tiles a gallery draws while its pictures are read, so the grid is there at once. */
|
|
454
|
+
const HELD_TILES = [0, 1, 2];
|
|
455
|
+
/** "2 of 20 · PNG, JPEG or WebP, up to 300 KB each": how many are kept, of how many, what kinds and how large. */
|
|
456
|
+
function limitsLine(owned) {
|
|
457
|
+
// A picture's size is the category's picture limit (Drive's settings), a document's the category's own.
|
|
458
|
+
const pictureBytes = pictureLimits(owned.category).maxBytes;
|
|
459
|
+
const takesPictures = owned.category.types.some(isPictureType);
|
|
460
|
+
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`;
|
|
461
|
+
return `${owned.files.length} of ${owned.category.maxCount} · ${fileKindsSentence(owned.category.types)}, ${sizes}`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Why a new file cannot be added: the category keeps no more. In the pictures' view a one-file category replaces, so it
|
|
465
|
+
* always can; the document list has no Replace, so there a full one-file category is held too.
|
|
466
|
+
*/
|
|
467
|
+
function noRoomReason(owned, replaces = true) {
|
|
468
|
+
if (roomLeft(owned) > 0 || (replaces && owned.category.maxCount === 1))
|
|
469
|
+
return null;
|
|
470
|
+
return owned.category.maxCount === 1 ? "This keeps one file: delete it first." : `This keeps at most ${owned.category.maxCount}: delete one first.`;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* An owner's pictures, or documents for a category that takes PDF: upload several up to the category's limit, make
|
|
474
|
+
* one the main picture, view them in the editor module's viewer, edit one (saved as a new picture), delete.
|
|
475
|
+
*
|
|
476
|
+
* Pictures come in two panes, as the 2026-06-20 gallery did: the main picture large, with Upload and Remove (no main
|
|
477
|
+
* picture; the pictures stay) under it and the count and kinds; beside it the pictures as a grid, each with its own
|
|
478
|
+
* buttons inside it - View, Make main, Edit, Delete (Make main and Delete not on the main one) - shown on hover, on a
|
|
479
|
+
* keyboard's focus in the picture, and always where nothing can hover (a touch screen). Under 560px of its own width
|
|
480
|
+
* the main picture sits small beside its buttons, over the grid.
|
|
481
|
+
*/
|
|
482
|
+
export function PictureGallery(props) {
|
|
483
|
+
const files = useOwnerFiles(props);
|
|
484
|
+
const preferences = useDatePreferences();
|
|
485
|
+
const { status, data, busy, canWrite, readOnlyReason, editorProblem, actions } = files;
|
|
486
|
+
const rootRef = useRef(null);
|
|
487
|
+
useFocusThroughChange(rootRef, busy);
|
|
488
|
+
// The main picture's original that could not be shown (a file gone since the list was read): the icon stands in.
|
|
489
|
+
const [coverFailed, setCoverFailed] = useState(null);
|
|
490
|
+
const documents = props.view ? props.view === "documents" : !files.pictures;
|
|
491
|
+
const noun = documents ? "file" : "picture";
|
|
492
|
+
const known = data ? (documents ? "documents" : "pictures") : null;
|
|
493
|
+
const onViewKnown = useRef(props.onViewKnown);
|
|
494
|
+
onViewKnown.current = props.onViewKnown;
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (known)
|
|
497
|
+
onViewKnown.current?.(known);
|
|
498
|
+
}, [known]);
|
|
499
|
+
if (status.kind === "failed") {
|
|
500
|
+
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" }) })] }));
|
|
501
|
+
}
|
|
502
|
+
const loading = status.kind === "loading" || status.kind === "idle";
|
|
503
|
+
const ratio = typeof props.aspect === "number" && props.aspect > 0 ? props.aspect : 1;
|
|
504
|
+
const shape = { "--nx-picture-ratio": ratio };
|
|
505
|
+
if (documents) {
|
|
506
|
+
if (loading) {
|
|
507
|
+
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"] }) }));
|
|
508
|
+
}
|
|
509
|
+
const owned = data;
|
|
510
|
+
// Held at the limit with its reason, as the pictures' Upload is; pressed, it says the refusal (`hasRoom`).
|
|
511
|
+
const full = noRoomReason(owned, false);
|
|
512
|
+
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) }), canWrite && (_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 }), 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."] }), canWrite && _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 => (_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)}`] })] }), canWrite && (_jsxs("div", { className: "nx-files__actions", children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Rename ${file.name}`, title: "Rename", ...busyProps(busy, () => void actions.rename(file)) }), _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)) })] }))] }, file.id))) }))] }));
|
|
513
|
+
}
|
|
514
|
+
const owned = loading ? null : data;
|
|
515
|
+
const kept = owned?.files ?? [];
|
|
516
|
+
const primary = kept.find(file => file.isPrimary) ?? null;
|
|
517
|
+
// A category that keeps one file: a new picture takes the place of the one kept, and Remove deletes it.
|
|
518
|
+
const single = owned?.category.maxCount === 1;
|
|
519
|
+
const noRoom = owned ? noRoomReason(owned) : null;
|
|
520
|
+
const coverUrl = primary ? getFileAdapter().fileUrl(primary, "original") : null;
|
|
521
|
+
const coverShown = coverUrl && coverFailed !== coverUrl;
|
|
522
|
+
const editHeld = busy || noRoom ? true : undefined;
|
|
523
|
+
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: [canWrite && owned && (_jsxs("div", { className: "nx-files__side-actions", children: [_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" }), 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" }))] })), _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 }), 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."] }), canWrite && _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 => (_jsxs("li", { className: "nx-files__tile", "data-primary": file.isPrimary || undefined, children: [_jsx("span", { className: "nx-files__thumb", children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) }), 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": file.isPrimary ? `${file.name}, the main picture` : file.name, 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 }), canWrite && !file.isPrimary && _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)) }), canWrite && (_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 })), canWrite && !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)) })] })] }, file.id))) })) })] }) }));
|
|
524
|
+
}
|
|
525
|
+
/** An owner's documents as a list: `PictureGallery` with `view="documents"`. */
|
|
526
|
+
export function DocumentList(props) {
|
|
527
|
+
return _jsx(PictureGallery, { ...props, view: "documents" });
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Opens an owner's pictures (or documents) in a window; one window per owner and category. Pictures fill a wide
|
|
531
|
+
* window edge to edge in their two panes, the grid scrolling; documents are a list in a narrower one. Without `view`,
|
|
532
|
+
* the title, icon and size follow what the category takes once its files are read: a category that takes PDF says
|
|
533
|
+
* "Documents".
|
|
534
|
+
*/
|
|
535
|
+
export function openPictureGallery({ popupTitle, ...props }) {
|
|
536
|
+
const heading = (view) => {
|
|
537
|
+
const what = view === "documents" ? "Documents" : "Pictures";
|
|
538
|
+
return {
|
|
539
|
+
title: popupTitle ?? (props.title ? `${what} · ${props.title}` : what),
|
|
540
|
+
icon: _jsx(InlineIcon, { icon: view === "documents" ? iconFileText : iconPhoto }),
|
|
541
|
+
size: view === "documents" ? "lg" : "xl",
|
|
542
|
+
flush: view !== "documents",
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
let shown = props.view ?? "pictures";
|
|
546
|
+
return openPopup({
|
|
547
|
+
key: `nx-files:${String(props.module)}:${props.category}:${props.ownerId}`,
|
|
548
|
+
...heading(shown),
|
|
549
|
+
content: popup => (_jsx(PictureGallery, { ...props, onViewKnown: view => {
|
|
550
|
+
props.onViewKnown?.(view);
|
|
551
|
+
if (view === shown)
|
|
552
|
+
return;
|
|
553
|
+
shown = view;
|
|
554
|
+
popup.update(heading(view));
|
|
555
|
+
} })),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* A column with each row's picture: every visible row's read in one call per page, and a click opens the row's
|
|
560
|
+
* gallery. A change there redraws the row's picture without a reload.
|
|
561
|
+
*
|
|
562
|
+
* @example
|
|
563
|
+
* columns: [pictureColumn({ module: NexusModule.Enterprise, category: "branch-picture", rowTitle: row => row.branchName }), ...]
|
|
564
|
+
*/
|
|
565
|
+
export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, view, ...column }) {
|
|
566
|
+
return {
|
|
567
|
+
key: "picture",
|
|
568
|
+
label: "Picture",
|
|
569
|
+
width: "4.5rem",
|
|
570
|
+
align: "center",
|
|
571
|
+
sortable: false,
|
|
572
|
+
searchable: false,
|
|
573
|
+
resizable: false,
|
|
574
|
+
...column,
|
|
575
|
+
value: () => null,
|
|
576
|
+
render: row => {
|
|
577
|
+
const id = readPath(row, ownerKey);
|
|
578
|
+
const ownerId = typeof id === "string" || typeof id === "number" ? String(id) : null;
|
|
579
|
+
if (!ownerId)
|
|
580
|
+
return null;
|
|
581
|
+
const title = rowTitle?.(row);
|
|
582
|
+
const owner = { module, category, ownerId };
|
|
583
|
+
const named = nameKey ? readPath(row, nameKey) : null;
|
|
584
|
+
const locked = typeof lockReason === "function" ? lockReason(row) : lockReason;
|
|
585
|
+
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, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
|
|
586
|
+
},
|
|
587
|
+
text: () => "",
|
|
588
|
+
};
|
|
589
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { type QrLevel } from "../../Helpers/QrCode.ts";
|
|
3
|
+
export interface QrCodeImageProps {
|
|
4
|
+
/** What the code holds: an authenticator's otpauth:// address, a link, any text, as UTF-8. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** What a screen reader says in place of the picture: "QR code to add your account to an authenticator app". */
|
|
7
|
+
label: string;
|
|
8
|
+
/** The error correction level, M unless told: L, M, Q and H restore about 7%, 15%, 25% and 30% of a damaged code. */
|
|
9
|
+
level?: QrLevel;
|
|
10
|
+
/** The page's own class, for where the code sits: a grid area, a margin. */
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A QR code as an image: one SVG path of black modules on a white square that holds the quiet zone, 9rem wide unless a
|
|
16
|
+
* class of the page's says otherwise (`nx-qr`). It stays black on white in a dark theme and in forced colours, since
|
|
17
|
+
* that is what a phone's camera reads. Nothing in it needs the browser, so a server component can render it. A text
|
|
18
|
+
* longer than a QR code holds throws, with how much it holds (`encodeQr`).
|
|
19
|
+
*
|
|
20
|
+
* @example <QrCodeImage value={setup.otpauthUri} label="QR code to add your account to an authenticator app" />
|
|
21
|
+
*/
|
|
22
|
+
export declare function QrCodeImage({ value, label, level, className, style }: QrCodeImageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
3
|
+
import { encodeQr, qrPath } from "../../Helpers/QrCode.js";
|
|
4
|
+
/**
|
|
5
|
+
* A QR code as an image: one SVG path of black modules on a white square that holds the quiet zone, 9rem wide unless a
|
|
6
|
+
* class of the page's says otherwise (`nx-qr`). It stays black on white in a dark theme and in forced colours, since
|
|
7
|
+
* that is what a phone's camera reads. Nothing in it needs the browser, so a server component can render it. A text
|
|
8
|
+
* longer than a QR code holds throws, with how much it holds (`encodeQr`).
|
|
9
|
+
*
|
|
10
|
+
* @example <QrCodeImage value={setup.otpauthUri} label="QR code to add your account to an authenticator app" />
|
|
11
|
+
*/
|
|
12
|
+
export function QrCodeImage({ value, label, level, className, style }) {
|
|
13
|
+
const { viewBox, d } = qrPath(encodeQr(value, level));
|
|
14
|
+
return (_jsxs("svg", { role: "img", "aria-label": label, viewBox: viewBox, shapeRendering: "crispEdges", className: cx("nx-qr", className), style: style, children: [_jsx("rect", { width: "100%", height: "100%", fill: "#fff" }), _jsx("path", { d: d, fill: "#000" })] }));
|
|
15
|
+
}
|