@protobase/ui 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/app/app.d.ts +2 -2
  2. package/dist/app/app.js +12 -9
  3. package/dist/app/current-address.d.ts +1 -0
  4. package/dist/app/current-address.js +2 -1
  5. package/dist/app/custom-section.js +1 -1
  6. package/dist/app/displays/custom-display.js +1 -1
  7. package/dist/app/displays/map-display.js +1 -1
  8. package/dist/app/displays/record-card.js +1 -1
  9. package/dist/app/gallery/gallery-items.js +37 -0
  10. package/dist/app/gallery/gallery-layout.js +17 -0
  11. package/dist/app/gallery/gallery-section.js +124 -0
  12. package/dist/app/gallery/lightbox.js +121 -0
  13. package/dist/app/gallery/photo-details.js +49 -0
  14. package/dist/app/gallery/use-gallery-photos.js +38 -0
  15. package/dist/app/pages/merge-ui.d.ts +1 -1
  16. package/dist/app/pages/merge-ui.js +12 -2
  17. package/dist/app/pages/project-ui.d.ts +24 -4
  18. package/dist/app/pages/project-ui.js +3 -3
  19. package/dist/app/pages/render-node.js +1 -1
  20. package/dist/app/record-lines.js +2 -2
  21. package/dist/app/record-mode/record-mode.js +31 -0
  22. package/dist/app/record-page.js +109 -28
  23. package/dist/app/use-live-save.js +4 -1
  24. package/dist/assistant-dock/widget-view.js +1 -1
  25. package/dist/auth/auth-gate.js +7 -1
  26. package/dist/auth/auth-provider.js +10 -0
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +4 -4
  29. package/dist/package.js +1 -1
  30. package/dist/plugins/before-sign-in.js +49 -0
  31. package/dist/plugins/plugin-gate.js +8 -8
  32. package/dist/plugins/plugin-source.js +32 -0
  33. package/dist/plugins/plugin-ui.js +5 -3
  34. package/dist/plugins/use-plugin-loads.js +3 -4
  35. package/dist/record-view/spec-table.js +25 -0
  36. package/dist/shell/assets/app-DYl-j-Nf.js +66 -0
  37. package/dist/shell/assets/index-BLnOMDD8.css +2 -0
  38. package/dist/shell/assets/{index-BXEkf4-O.js → index-Bes91xE3.js} +1 -1
  39. package/dist/shell/assets/{maplibre-DCGyNbM2.js → maplibre-BDe9GhVw.js} +2 -2
  40. package/dist/shell/assets/{record-map-DEn8Ulev.js → record-map-CY2LFq1z.js} +1 -1
  41. package/dist/shell/assets/{records-map-DdLiVxEp.js → records-map-B_DHVvmx.js} +1 -1
  42. package/dist/shell/assets/shared-protobase-ui-Bb2W4Il8.js +1 -0
  43. package/dist/shell/index.html +4 -4
  44. package/dist/shell/protobase.shell.json +1 -1
  45. package/package.json +4 -4
  46. package/dist/shell/assets/app-j50iAbLC.js +0 -66
  47. package/dist/shell/assets/index-D0AzKE47.css +0 -2
  48. package/dist/shell/assets/shared-protobase-ui-zMOavpbt.js +0 -1
@@ -1,6 +1,6 @@
1
- import { useProjectUi } from "./project-ui.js";
2
1
  import { Problem } from "./problem.js";
3
2
  import { CustomBoundary } from "./custom-boundary.js";
3
+ import { useProjectUi } from "./project-ui.js";
4
4
  import { ActionBlock } from "./blocks/action.js";
5
5
  import { CardBlock } from "./blocks/card.js";
6
6
  import { CardRowBlock } from "./blocks/card-row.js";
@@ -14,7 +14,7 @@ const toLine = (record) => ({
14
14
  unitPriceCents: Math.round(Number(record.unitPrice) * 100)
15
15
  });
16
16
  /** The lines of a record. Reordering only changes `draft`; the record's Save writes it, in one batch. */
17
- const RecordLines = ({ lines, draft, vatRate, saving }) => {
17
+ const RecordLines = ({ lines, draft, vatRate, saving, locked = false }) => {
18
18
  const canReorder = can(usePermissions(lines.name), "update");
19
19
  return /* @__PURE__ */ jsx(Section, {
20
20
  title: "Lines",
@@ -29,7 +29,7 @@ const RecordLines = ({ lines, draft, vatRate, saving }) => {
29
29
  onReorder: (next) => draft.setOrder(next.map((line) => line.id)),
30
30
  vatRate,
31
31
  readOnly: true,
32
- reorderable: canReorder && !saving
32
+ reorderable: canReorder && !saving && !locked
33
33
  })
34
34
  })
35
35
  })
@@ -0,0 +1,31 @@
1
+ //#region src/app/record-mode/record-mode.ts
2
+ /**
3
+ * What the record page shows a user, by the view's `mode`, whether they may change the record and whether its address
4
+ * asks for the form (`edit`). Always editable (`edit`, the default) shows the form, locked to a user who may not change
5
+ * it, and ignores `edit`; `read` and `locked` show the read view or the locked form until the address asks for the
6
+ * form, which only a user who may change the record gets. `offersEdit` is whether the page links to the form;
7
+ * `editing` whether the record is being edited, which decides the help shown only while editing.
8
+ */
9
+ const recordShown = (mode, canUpdate, edit) => {
10
+ if (mode === void 0 || mode === "edit") return {
11
+ shown: canUpdate ? "form" : "locked",
12
+ offersEdit: false,
13
+ editing: canUpdate
14
+ };
15
+ if (canUpdate && edit) return {
16
+ shown: "form",
17
+ offersEdit: false,
18
+ editing: true
19
+ };
20
+ return {
21
+ shown: mode,
22
+ offersEdit: canUpdate,
23
+ editing: false
24
+ };
25
+ };
26
+ /** Whether a help line shows: always, unless it, or else the view, says only while editing and the record is not. */
27
+ const helpShown = (own, view, editing) => (own ?? view?.helpWhen ?? "always") === "always" || editing;
28
+ /** A field's help line on the record page, when it shows. */
29
+ const fieldHelp = (hints, view, editing) => hints?.help !== void 0 && helpShown(hints.helpWhen, view, editing) ? hints.help : void 0;
30
+ //#endregion
31
+ export { fieldHelp, helpShown, recordShown };
@@ -1,6 +1,8 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { inApp } from "../app-shell/breadcrumb.js";
1
3
  import { useAuth } from "../auth/auth-provider.js";
2
4
  import { formatRelativeTime } from "../format/date.js";
3
- import { Button } from "../primitives/button.js";
5
+ import { Button, buttonClass } from "../primitives/button.js";
4
6
  import { keys } from "../data/query-keys.js";
5
7
  import { useClient } from "../data/api-provider.js";
6
8
  import { useNavigationGuard, useRouter } from "./router.js";
@@ -10,7 +12,7 @@ import { humanize } from "../live/naming.js";
10
12
  import { useProjectUi } from "./pages/project-ui.js";
11
13
  import { displayValue } from "../live/display-value.js";
12
14
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
13
- import { useAppAddress } from "./use-app-address.js";
15
+ import { AppLink, useAppAddress } from "./use-app-address.js";
14
16
  import { ErrorBanner } from "./error-banner.js";
15
17
  import { shownWidget } from "./field-types/shown-widget.js";
16
18
  import { WidgetPart } from "./field-types/widget-part.js";
@@ -20,10 +22,12 @@ import { Field } from "../record-view/field.js";
20
22
  import { Section } from "../record-view/section.js";
21
23
  import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
22
24
  import { RecordHeader } from "../record-view/record-header.js";
25
+ import { SpecTable } from "../record-view/spec-table.js";
23
26
  import { toneFor } from "../live/columns-from-model.js";
24
27
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
25
28
  import { useRestore } from "./delete/use-restore.js";
26
29
  import { ValueBadge } from "./value-badge.js";
30
+ import { FieldValue } from "./pages/field-value.js";
27
31
  import { auditField } from "../live/audit-fields.js";
28
32
  import { CustomSection } from "./custom-section.js";
29
33
  import { MapSection } from "./map/map-section.js";
@@ -32,11 +36,13 @@ import { RecordLines, findLinesResource } from "./record-lines.js";
32
36
  import { draftStorageKey } from "./draft-storage.js";
33
37
  import { useLines } from "./use-lines.js";
34
38
  import { useLinesDraft } from "./use-lines-draft.js";
39
+ import { GallerySection } from "./gallery/gallery-section.js";
40
+ import { fieldHelp, helpShown, recordShown } from "./record-mode/record-mode.js";
35
41
  import { useRecordEditor } from "./use-record-editor.js";
36
42
  import { useRelatedRecords } from "./use-related-records.js";
37
43
  import { RelatedSection } from "./related-section.js";
38
44
  import { Fragment, useMemo } from "react";
39
- import { Check } from "lucide-react";
45
+ import { Check, Pencil } from "lucide-react";
40
46
  import { isApiError } from "@protobase/client";
41
47
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
42
48
  import { useQueryClient } from "@tanstack/react-query";
@@ -45,16 +51,17 @@ import { enumLabel } from "@protobase/schema";
45
51
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
46
52
  /**
47
53
  * The view's layout, or one section with the editable fields when the view has none. A user who may only read the
48
- * resource has every field read-only, so they get every field, locked, rather than an empty section.
54
+ * resource has every field read-only, so they get every field, locked, rather than an empty section; so does the read
55
+ * view, which shows the record rather than what can be changed.
49
56
  */
50
- const layoutFor = (model, view) => {
57
+ const layoutFor = (model, view, read) => {
51
58
  if (view && view.layout.length > 0) return view.layout;
52
59
  const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
53
60
  const editable = shown.filter((field) => !field.readOnly);
54
61
  return [{
55
62
  kind: "section",
56
63
  title: "Details",
57
- fields: (editable.length > 0 ? editable : shown).map((field) => field.name)
64
+ fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
58
65
  }];
59
66
  };
60
67
  const RecordPage = ({ resource, recordKey }) => {
@@ -92,7 +99,7 @@ const RecordPage = ({ resource, recordKey }) => {
92
99
  const LoadedRecord = ({ model, recordKey, stored }) => {
93
100
  const meta = useAdminMeta();
94
101
  const { basePath } = useRouter();
95
- const { go, open } = useAppAddress();
102
+ const { address, go, open } = useAppAddress();
96
103
  const view = meta.views[model.name];
97
104
  const singular = view?.names?.singular ?? humanize(model.name);
98
105
  const title = recordTitle(model, view, stored.record, recordKey);
@@ -109,12 +116,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
109
116
  draftKey: auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
110
117
  lines: linesDraft
111
118
  });
112
- useNavigationGuard(editor.dirty);
113
119
  const queryClient = useQueryClient();
114
120
  const client = useClient();
115
121
  const permissions = usePermissions(model.name);
116
122
  const restore = useRestore(model);
117
123
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
124
+ const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
125
+ useNavigationGuard(editor.dirty && shown === "form");
126
+ const readAddress = {
127
+ kind: "record",
128
+ resource: model.name,
129
+ key: recordKey
130
+ };
131
+ const editAddress = {
132
+ ...readAddress,
133
+ edit: true
134
+ };
135
+ const leavesForm = shown === "form" && view?.recordMode !== void 0 && view.recordMode !== "edit";
118
136
  const canDelete = can(permissions, "delete") && stored.permissions?.delete !== false;
119
137
  const remove = useDeleteFlow({
120
138
  model,
@@ -128,7 +146,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
128
146
  });
129
147
  const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
130
148
  const { fields: fieldTypes } = useProjectUi();
131
- const layout = layoutFor(model, view);
149
+ const layout = layoutFor(model, view, shown === "read");
132
150
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
133
151
  const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
134
152
  const updatedAt = auditField(model, "updated");
@@ -142,7 +160,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
142
160
  const conflict = editor.conflicts[name];
143
161
  return /* @__PURE__ */ jsx(Field, {
144
162
  label: hint(name)?.label ?? humanize(name),
145
- help: hint(name)?.help,
163
+ help: fieldHelp(hint(name), view, editing),
146
164
  error: editor.errors[name],
147
165
  wide: Boolean(hint(name)?.multiline),
148
166
  conflict: conflict === void 0 ? void 0 : {
@@ -163,7 +181,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
163
181
  stored: stored.record[name],
164
182
  relationLabel: related.labels[name],
165
183
  relationTo: related.addresses[name],
166
- locked: !canUpdate || access === "read",
184
+ locked: shown !== "form" || access === "read",
167
185
  invalid: Boolean(editor.errors[name]),
168
186
  reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
169
187
  resource: model.name,
@@ -171,6 +189,30 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
171
189
  })
172
190
  }, field.sensitive ? `${name}:${stored.etag}` : name);
173
191
  };
192
+ const readRow = (name) => {
193
+ const field = model.fields[name];
194
+ const value = field.sensitive ? /* @__PURE__ */ jsx(FieldEditor, {
195
+ field,
196
+ hints: hint(name),
197
+ value: "",
198
+ stored: stored.record[name],
199
+ locked: true,
200
+ reveal: () => client.reveal(model.name, recordKey, name),
201
+ resource: model.name,
202
+ onChange: (next) => editor.change(name, next)
203
+ }) : /* @__PURE__ */ jsx(FieldValue, {
204
+ model,
205
+ field,
206
+ value: stored.record[name],
207
+ relationLabel: related.labels[name]
208
+ });
209
+ return {
210
+ label: hint(name)?.label ?? humanize(name),
211
+ help: fieldHelp(hint(name), view, editing),
212
+ wide: Boolean(hint(name)?.multiline),
213
+ value
214
+ };
215
+ };
174
216
  const firstSidebar = layout.findIndex((item) => item.kind === "sidebar");
175
217
  const summary = /* @__PURE__ */ jsx(MetadataCard, {
176
218
  title: "Summary",
@@ -206,34 +248,51 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
206
248
  etag: stored.etag,
207
249
  permissions: stored.permissions
208
250
  };
209
- const saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
251
+ const save = async () => {
252
+ if (await editor.save() && leavesForm) go(readAddress, { replace: true });
253
+ };
254
+ const deleteButton = canDelete && /* @__PURE__ */ jsx(Button, {
255
+ variant: "danger",
256
+ className: "max-md:min-h-11",
257
+ onClick: () => void remove.start({
258
+ key: recordKey,
259
+ title,
260
+ etag: stored.etag,
261
+ snapshot: stored.record
262
+ }),
263
+ children: "Delete"
264
+ });
265
+ const saveControls = offersEdit ? /* @__PURE__ */ jsxs(Fragment$1, { children: [deleteButton, /* @__PURE__ */ jsxs(AppLink, {
266
+ to: editAddress,
267
+ className: cn(buttonClass({ variant: "primary" }), "max-md:min-h-11 max-md:flex-1"),
268
+ children: [/* @__PURE__ */ jsx(Pencil, {
269
+ className: "size-3.5",
270
+ "aria-hidden": true
271
+ }), "Edit"]
272
+ })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
210
273
  editor.dirty && /* @__PURE__ */ jsx("span", {
211
274
  className: "text-xs text-muted-foreground",
212
275
  children: "Unsaved changes"
213
276
  }),
214
- editor.dirty && canUpdate && /* @__PURE__ */ jsx(Button, {
277
+ editor.dirty && canUpdate && !leavesForm && /* @__PURE__ */ jsx(Button, {
215
278
  className: "max-md:min-h-11",
216
279
  disabled: editor.saving,
217
280
  onClick: editor.discard,
218
281
  children: "Discard"
219
282
  }),
220
- canDelete && /* @__PURE__ */ jsx(Button, {
221
- variant: "danger",
222
- className: "max-md:min-h-11",
223
- onClick: () => void remove.start({
224
- key: recordKey,
225
- title,
226
- etag: stored.etag,
227
- snapshot: stored.record
228
- }),
229
- children: "Delete"
283
+ leavesForm && /* @__PURE__ */ jsx(AppLink, {
284
+ to: readAddress,
285
+ className: cn(buttonClass(), "max-md:min-h-11"),
286
+ onClick: (event) => inApp(event) && editor.discard(),
287
+ children: "Cancel"
230
288
  }),
289
+ deleteButton,
231
290
  canUpdate && /* @__PURE__ */ jsxs(Button, {
232
291
  variant: "primary",
233
292
  className: "max-md:min-h-11 max-md:flex-1",
234
293
  loading: editor.saving,
235
294
  disabled: !editor.dirty && editor.phase !== "saved",
236
- onClick: () => void editor.save(),
295
+ onClick: () => void save(),
237
296
  children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
238
297
  })
239
298
  ] });
@@ -273,7 +332,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
273
332
  error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
274
333
  })
275
334
  }),
276
- editor.restored && /* @__PURE__ */ jsxs("div", {
335
+ shown !== "form" && offersEdit && editor.dirty && /* @__PURE__ */ jsxs("div", {
336
+ className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
337
+ role: "status",
338
+ children: [/* @__PURE__ */ jsx("span", { children: "You have unsaved changes to this record." }), /* @__PURE__ */ jsxs("span", {
339
+ className: "flex gap-2",
340
+ children: [/* @__PURE__ */ jsx(AppLink, {
341
+ to: editAddress,
342
+ className: buttonClass({ size: "sm" }),
343
+ children: "Continue editing"
344
+ }), /* @__PURE__ */ jsx(Button, {
345
+ size: "sm",
346
+ onClick: editor.discard,
347
+ children: "Discard"
348
+ })]
349
+ })]
350
+ }),
351
+ shown === "form" && editor.restored && /* @__PURE__ */ jsxs("div", {
277
352
  className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
278
353
  role: "status",
279
354
  children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
@@ -307,13 +382,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
307
382
  item,
308
383
  record: stored.record
309
384
  }, `map:${index}`)];
385
+ if (item.kind === "gallery") return [/* @__PURE__ */ jsx(GallerySection, {
386
+ item,
387
+ record: stored.record,
388
+ recordKey
389
+ }, `gallery:${index}`)];
310
390
  if (item.kind !== "section") return [];
311
391
  const fields = item.fields.filter((name) => model.fields[name] && visible(name));
312
392
  if (fields.length === 0) return [];
313
393
  return [/* @__PURE__ */ jsx(Section, {
314
394
  title: item.title,
315
- help: item.help,
316
- children: /* @__PURE__ */ jsx("div", {
395
+ help: helpShown(item.helpWhen, view, editing) ? item.help : void 0,
396
+ children: shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
317
397
  className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
318
398
  children: fields.map(fieldView)
319
399
  })
@@ -322,7 +402,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
322
402
  lines: linesModel,
323
403
  draft: linesDraft,
324
404
  vatRate: Number(stored.record.vatRate ?? 21) / 100,
325
- saving: editor.saving
405
+ saving: editor.saving,
406
+ locked: shown !== "form"
326
407
  })]
327
408
  }), /* @__PURE__ */ jsxs("aside", {
328
409
  className: "space-y-4 md:pt-8",
@@ -5,7 +5,8 @@ import { isApiError } from "@protobase/client";
5
5
  const savedForMs = 2e3;
6
6
  /**
7
7
  * Shows the outcome of a save the way the view asks: a toast (spinner, then check) or the state of
8
- * the Save button. API errors are reported and not rethrown; anything else propagates.
8
+ * the Save button. API errors are reported and not rethrown; anything else propagates. `run` resolves to whether the
9
+ * save went through.
9
10
  */
10
11
  const useLiveSave = (mode, subject) => {
11
12
  const toast = useToast();
@@ -30,6 +31,7 @@ const useLiveSave = (mode, subject) => {
30
31
  setPhase("saved");
31
32
  timer.current = setTimeout(() => setPhase("idle"), savedForMs);
32
33
  }
34
+ return true;
33
35
  } catch (error) {
34
36
  if (!isApiError(error)) throw error;
35
37
  const title = error.status === 412 ? "Changed by someone else" : "Could not save";
@@ -42,6 +44,7 @@ const useLiveSave = (mode, subject) => {
42
44
  setPhase("idle");
43
45
  setMessage(`${title}: ${error.message}`);
44
46
  }
47
+ return false;
45
48
  }
46
49
  };
47
50
  return {
@@ -1,5 +1,5 @@
1
- import { ActionCard } from "../action-card/action-card.js";
2
1
  import { reportRenderError } from "../app/error-reporting.js";
2
+ import { ActionCard } from "../action-card/action-card.js";
3
3
  import { useProjectUi } from "../app/pages/project-ui.js";
4
4
  import { Component } from "react";
5
5
  import { jsx } from "react/jsx-runtime";
@@ -19,6 +19,7 @@ import { TwoFactorPage } from "./two-factor-page.js";
19
19
  import { useSignInAddress } from "./use-sign-in-address.js";
20
20
  import { readInvitationLink, withoutInvitationLink } from "../organizations/invitation-link.js";
21
21
  import { InvitationPage } from "../organizations/invitation-page.js";
22
+ import { BeforeSignIn } from "../plugins/before-sign-in.js";
22
23
  import { useCallback, useEffect, useState } from "react";
23
24
  import { jsx, jsxs } from "react/jsx-runtime";
24
25
  import { signInPath } from "@protobase/schema";
@@ -62,9 +63,14 @@ const Redirect = ({ to }) => {
62
63
  * browse, and the right page otherwise: the step of the sign-in route the address names, or the page an emailed reset
63
64
  * link, an invitation link or a staff link opened. Someone signed out without a visitor's browsing goes to the sign-in
64
65
  * route, and signing in comes back to the page the route was opened from. An invited person with an account signs in
65
- * first and comes back to the invitation.
66
+ * first and comes back to the invitation. The plugins the app loads before sign-in draw around what someone signed
67
+ * out sees (see `BeforeSignIn`), while the gate keeps its state.
66
68
  */
67
69
  const AuthGate = ({ workspace, children }) => {
70
+ const page = useAuthPage(workspace, children);
71
+ return /* @__PURE__ */ jsx(BeforeSignIn, { children: page });
72
+ };
73
+ const useAuthPage = (workspace, children) => {
68
74
  const { state, recheck, passwordReset, signOut, signInProviders, canSignUp, browse } = useAuth();
69
75
  const { path, params, basePath, navigate } = useRouter();
70
76
  const { address, next, open, link, leave } = useSignInAddress();
@@ -21,6 +21,7 @@ const AuthProvider = ({ session: given, children }) => {
21
21
  const [staffSignIn, setStaffSignIn] = useState();
22
22
  const [organizations, setOrganizations] = useState();
23
23
  const [anonymous, setAnonymous] = useState(false);
24
+ const [beforeSignIn, setBeforeSignIn] = useState([]);
24
25
  const signedOutState = useCallback(() => anonymous ? { kind: "visitor" } : { kind: "signed-out" }, [anonymous]);
25
26
  const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
26
27
  const signedOut = useRef(markSignedOut);
@@ -54,6 +55,7 @@ const AuthProvider = ({ session: given, children }) => {
54
55
  setStaffSignIn(status.staffSignIn);
55
56
  setOrganizations(status.organizations);
56
57
  setAnonymous(Boolean(status.anonymous));
58
+ setBeforeSignIn(status.plugins ?? []);
57
59
  if (status.needsAdmin) return setState({ kind: "needs-admin" });
58
60
  const user = await session.session();
59
61
  return user ? enter(user) : setState({ kind: status.anonymous ? "visitor" : "signed-out" });
@@ -79,6 +81,13 @@ const AuthProvider = ({ session: given, children }) => {
79
81
  ...organizations && { organizations },
80
82
  markSignedOut,
81
83
  recheck,
84
+ beforeSignIn,
85
+ adoptSession: async () => {
86
+ const user = await session.session();
87
+ if (!user) return false;
88
+ await enter(user);
89
+ return true;
90
+ },
82
91
  ...anonymous && { browse: () => {
83
92
  setState((current) => current.kind === "signed-out" ? { kind: "visitor" } : current);
84
93
  } },
@@ -116,6 +125,7 @@ const AuthProvider = ({ session: given, children }) => {
116
125
  staffSignIn,
117
126
  organizations,
118
127
  anonymous,
128
+ beforeSignIn,
119
129
  signedOutState,
120
130
  markSignedOut,
121
131
  recheck,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
2
- import { ActionContext, ActionHandler, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
2
+ import { ActionContext, ActionHandler, BeforeSignInProps, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
3
3
  import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
4
4
  import { App, AppProps } from "./app/app.js";
5
5
  import "./app/index.js";
@@ -42,4 +42,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
42
42
  import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
43
43
  import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
44
44
  import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
45
- export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, ResponsiveDialog, type ResponsiveDialogProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, type Step, StepList, type StepState, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
45
+ export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, type DiffLine, DiffView, type DiffViewProps, FieldList, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type RecordCardProps, RecordLink, ResponsiveDialog, type ResponsiveDialogProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, type Step, StepList, type StepState, definePlugin, defineUi, diffStats, isConflict, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, useRecord, useResourceClient, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { OtpInput } from "./primitives/otp-input.js";
2
2
  import { ApiProvider, useClient, useResourceClient } from "./data/api-provider.js";
3
+ import { uiFromModule } from "./app/pages/ui-from-module.js";
4
+ import { mergeUi } from "./app/pages/merge-ui.js";
5
+ import { usePlugin } from "./plugins/plugin-context.js";
6
+ import { pluginApi, protobaseVersion } from "./plugins/plugin-api.js";
3
7
  import { useApiToken } from "./auth/use-api-token.js";
4
8
  import { useRecord } from "./data/use-record.js";
5
9
  import { ChatMessage } from "./chat/chat-message.js";
@@ -25,10 +29,6 @@ import { SignInPrompt } from "./auth/sign-in-prompt.js";
25
29
  import { useListDisplay } from "./app/displays/display-context.js";
26
30
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
27
31
  import { useLayoutRecord } from "./app/pages/record-scope.js";
28
- import { uiFromModule } from "./app/pages/ui-from-module.js";
29
- import { mergeUi } from "./app/pages/merge-ui.js";
30
- import { usePlugin } from "./plugins/plugin-context.js";
31
- import { pluginApi, protobaseVersion } from "./plugins/plugin-api.js";
32
32
  import { useList } from "./data/use-list.js";
33
33
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
34
34
  import { App } from "./app/app.js";
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.18.0";
2
+ var version = "0.19.1";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,49 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { useAuth } from "../auth/auth-provider.js";
3
+ import { useRouter } from "../app/router.js";
4
+ import { CustomBoundary } from "../app/pages/custom-boundary.js";
5
+ import { mergePlugins } from "./merge-plugins.js";
6
+ import { usePluginSource } from "./plugin-source.js";
7
+ import { usePluginLoads } from "./use-plugin-loads.js";
8
+ import { useCallback, useMemo } from "react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/plugins/before-sign-in.tsx
11
+ /**
12
+ * Loads the plugins the setup status names to everyone, from the app's plugin source, and draws their `beforeSignIn`
13
+ * components around `children` for someone signed out or a visitor, once they have loaded, or failed, or after a wait
14
+ * (see `usePluginLoads`). Signed in, it draws `children` alone, and the plugins stay loaded for the app to merge with
15
+ * the others `/meta` names. A `beforeSignIn` that throws leaves `children` as they are without it.
16
+ */
17
+ const BeforeSignIn = ({ children }) => {
18
+ const { state, beforeSignIn, adoptSession } = useAuth();
19
+ const { path, params, navigate } = useRouter();
20
+ const { loads, settled } = usePluginLoads(beforeSignIn, usePluginSource());
21
+ const Around = useMemo(() => mergePlugins(void 0, loads).ui.beforeSignIn, [loads]);
22
+ const removeParams = useCallback((names) => {
23
+ const kept = new URLSearchParams(params);
24
+ for (const name of names) kept.delete(name);
25
+ navigate(kept.size > 0 ? `${path}?${kept}` : path, { replace: true });
26
+ }, [
27
+ path,
28
+ params,
29
+ navigate
30
+ ]);
31
+ if (state.kind !== "signed-out" && state.kind !== "visitor") return children;
32
+ if (!settled) return /* @__PURE__ */ jsxs("div", {
33
+ className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
34
+ children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
35
+ });
36
+ if (!Around) return children;
37
+ return /* @__PURE__ */ jsx(CustomBoundary, {
38
+ name: "beforeSignIn",
39
+ fallback: children,
40
+ children: /* @__PURE__ */ jsx(Around, {
41
+ signedIn: adoptSession,
42
+ params,
43
+ removeParams,
44
+ children
45
+ })
46
+ });
47
+ };
48
+ //#endregion
49
+ export { BeforeSignIn };
@@ -1,9 +1,9 @@
1
1
  import { Spinner } from "../primitives/spinner.js";
2
- import { ProjectUiContext } from "../app/pages/project-ui.js";
3
- import { useAdminMeta } from "../app/meta-gate.js";
4
- import { browserLoader } from "./load-plugin.js";
5
2
  import { mergePlugins } from "./merge-plugins.js";
3
+ import { usePluginSource } from "./plugin-source.js";
6
4
  import { usePluginLoads } from "./use-plugin-loads.js";
5
+ import { ProjectUiContext } from "../app/pages/project-ui.js";
6
+ import { useAdminMeta } from "../app/meta-gate.js";
7
7
  import { createContext, useContext, useMemo } from "react";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/plugins/plugin-gate.tsx
@@ -11,13 +11,13 @@ const PluginsOffContext = createContext([]);
11
11
  /** The plugins `/meta` names that the app did not load, and why. */
12
12
  const usePluginsOff = () => useContext(PluginsOffContext);
13
13
  /**
14
- * Loads the plugins `/meta` names and provides the project's UI config with theirs merged in. It draws its children
15
- * once they have loaded, or failed, or after a wait (see `usePluginLoads`); a plugin that fails is left out, and its
16
- * components draw what any missing component draws.
14
+ * Loads the plugins `/meta` names, from the app's plugin source, and provides the project's UI config with theirs
15
+ * merged in. It draws its children once they have loaded, or failed, or after a wait (see `usePluginLoads`); a plugin
16
+ * that fails is left out, and its components draw what any missing component draws.
17
17
  */
18
- const PluginGate = ({ ui, loader = browserLoader, children }) => {
18
+ const PluginGate = ({ ui, children }) => {
19
19
  const { plugins = [] } = useAdminMeta();
20
- const { loads, settled } = usePluginLoads(plugins, loader);
20
+ const { loads, settled } = usePluginLoads(plugins, usePluginSource());
21
21
  const merged = useMemo(() => mergePlugins(ui, loads), [ui, loads]);
22
22
  if (!settled) return /* @__PURE__ */ jsxs("div", {
23
23
  className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
@@ -0,0 +1,32 @@
1
+ import { browserLoader, loadPlugin } from "./load-plugin.js";
2
+ import { createContext, useContext, useMemo } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/plugins/plugin-source.tsx
5
+ /** A source that loads with `loader` and keeps each plugin's load, so one named before and after sign-in loads once. */
6
+ const createPluginSource = (loader) => {
7
+ const loads = /* @__PURE__ */ new Map();
8
+ return { load: (ref) => {
9
+ const key = JSON.stringify(ref);
10
+ const known = loads.get(key);
11
+ if (known) return known;
12
+ const load = loadPlugin(ref, loader, window.location.href);
13
+ loads.set(key, load);
14
+ return load;
15
+ } };
16
+ };
17
+ const PluginSourceContext = createContext(null);
18
+ /** The app's plugin source, loading with `loader`: the page's `fetch` and `import()` by default. */
19
+ const PluginSourceProvider = ({ loader = browserLoader, children }) => {
20
+ const source = useMemo(() => createPluginSource(loader), [loader]);
21
+ return /* @__PURE__ */ jsx(PluginSourceContext.Provider, {
22
+ value: source,
23
+ children
24
+ });
25
+ };
26
+ const usePluginSource = () => {
27
+ const source = useContext(PluginSourceContext);
28
+ if (!source) throw new Error("usePluginSource must be used inside PluginSourceProvider");
29
+ return source;
30
+ };
31
+ //#endregion
32
+ export { PluginSourceProvider, createPluginSource, usePluginSource };
@@ -19,8 +19,8 @@ const fieldWithin = (plugin, name, widget) => ({
19
19
  });
20
20
  /**
21
21
  * A plugin's UI config from its module, read as a `protobase.ui.tsx` module and merged with the configs it extends,
22
- * named after the plugin: its components, field types and shell slots draw inside the plugin. Throws `UiConflictError`
23
- * when the configs it merges define a name twice.
22
+ * named after the plugin: its components, field types, shell slots and `beforeSignIn` draw inside the plugin. Throws
23
+ * `UiConflictError` when the configs it merges define a name twice.
24
24
  */
25
25
  const pluginUi = (plugin, module) => {
26
26
  const ui = mergeUi({
@@ -33,12 +33,14 @@ const pluginUi = (plugin, module) => {
33
33
  ...ui.shell.actions && { actions: within(plugin, ui.shell.actions, "shell.actions") },
34
34
  ...ui.shell.rightPanel && { rightPanel: within(plugin, ui.shell.rightPanel, "shell.rightPanel") }
35
35
  };
36
+ const beforeSignIn = ui.beforeSignIn && within(plugin, ui.beforeSignIn, "beforeSignIn");
36
37
  return {
37
38
  name: plugin.name,
38
39
  components,
39
40
  ...ui.actions && { actions: ui.actions },
40
41
  ...fields && { fields },
41
- ...shell && { shell }
42
+ ...shell && { shell },
43
+ ...beforeSignIn && { beforeSignIn }
42
44
  };
43
45
  };
44
46
  //#endregion
@@ -1,4 +1,3 @@
1
- import { loadPlugin } from "./load-plugin.js";
2
1
  import { useEffect, useState } from "react";
3
2
  //#region src/plugins/use-plugin-loads.ts
4
3
  /** How long the app waits for its plugins before it draws without the ones still loading, which arrive later. */
@@ -8,7 +7,7 @@ const none = [];
8
7
  * The plugins `refs` names, in their order, as they load. `settled` once every one has loaded or failed, or after
9
8
  * `pluginWaitMs`, so a slow plugin delays the app only that long. Without plugins it is settled at once.
10
9
  */
11
- const usePluginLoads = (refs, loader) => {
10
+ const usePluginLoads = (refs, source) => {
12
11
  const key = JSON.stringify(refs);
13
12
  const [state, setState] = useState({
14
13
  key: "[]",
@@ -34,7 +33,7 @@ const usePluginLoads = (refs, loader) => {
34
33
  publish();
35
34
  }, pluginWaitMs);
36
35
  refs.forEach((ref, index) => {
37
- loadPlugin(ref, loader, window.location.href).then((load) => {
36
+ source.load(ref).then((load) => {
38
37
  results[index] = load;
39
38
  publish();
40
39
  });
@@ -43,7 +42,7 @@ const usePluginLoads = (refs, loader) => {
43
42
  active = false;
44
43
  clearTimeout(timer);
45
44
  };
46
- }, [key, loader]);
45
+ }, [key, source]);
47
46
  if (refs.length === 0) return {
48
47
  loads: none,
49
48
  settled: true