@protobase/ui 0.19.1 → 0.19.3

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 (49) hide show
  1. package/dist/app/app.js +2 -0
  2. package/dist/app/favicon.svg +1 -0
  3. package/dist/app/gallery/detail-fields.js +12 -0
  4. package/dist/app/gallery/gallery-layout.js +3 -1
  5. package/dist/app/gallery/gallery-section.js +7 -4
  6. package/dist/app/gallery/lightbox.js +38 -44
  7. package/dist/app/gallery/photo-details.js +21 -28
  8. package/dist/app/pages/merge-ui.d.ts +1 -1
  9. package/dist/app/pages/merge-ui.js +5 -3
  10. package/dist/app/pages/project-ui.d.ts +6 -1
  11. package/dist/app/pages/record-actions.js +32 -6
  12. package/dist/app/project-logo.js +17 -0
  13. package/dist/app/record-mode/record-mode.js +12 -7
  14. package/dist/app/record-page.js +27 -38
  15. package/dist/app-shell/app-shell.d.ts +1 -1
  16. package/dist/app-shell/app-shell.js +3 -2
  17. package/dist/app-shell/index.d.ts +1 -0
  18. package/dist/app-shell/logo.d.ts +11 -0
  19. package/dist/app-shell/logo.js +30 -32
  20. package/dist/app-shell/sidebar.d.ts +2 -1
  21. package/dist/app-shell/sidebar.js +3 -3
  22. package/dist/app-shell/wordmark-path.js +4 -0
  23. package/dist/auth/auth-layout.d.ts +10 -0
  24. package/dist/auth/code-form.d.ts +29 -0
  25. package/dist/auth/resend-code-button.d.ts +11 -0
  26. package/dist/format/date.js +2 -15
  27. package/dist/index.d.ts +8 -1
  28. package/dist/index.js +8 -1
  29. package/dist/package.js +1 -1
  30. package/dist/primitives/button.d.ts +11 -0
  31. package/dist/primitives/input.d.ts +11 -0
  32. package/dist/primitives/spinner.d.ts +5 -0
  33. package/dist/record-view/action-rail.js +22 -11
  34. package/dist/record-view/record-header.js +5 -5
  35. package/dist/shell/assets/app-ChEqQ9ca.js +66 -0
  36. package/dist/shell/assets/{index-Bes91xE3.js → index-5frCs7fQ.js} +1 -1
  37. package/dist/shell/assets/index-D-4IHhqX.css +2 -0
  38. package/dist/shell/assets/maplibre-D8plsjP2.js +814 -0
  39. package/dist/shell/assets/{record-map-CY2LFq1z.js → record-map-BDJTOwZo.js} +1 -1
  40. package/dist/shell/assets/records-map-C-XKw6m1.js +1 -0
  41. package/dist/shell/assets/shared-protobase-ui-Ceamz_7l.js +1 -0
  42. package/dist/shell/index.html +5 -4
  43. package/dist/shell/protobase.shell.json +1 -1
  44. package/package.json +6 -5
  45. package/dist/shell/assets/app-DYl-j-Nf.js +0 -66
  46. package/dist/shell/assets/index-BLnOMDD8.css +0 -2
  47. package/dist/shell/assets/maplibre-BDe9GhVw.js +0 -814
  48. package/dist/shell/assets/records-map-B_DHVvmx.js +0 -1
  49. package/dist/shell/assets/shared-protobase-ui-Bb2W4Il8.js +0 -1
package/dist/app/app.js CHANGED
@@ -36,6 +36,7 @@ import { useActiveRole } from "../roles/use-active-role.js";
36
36
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
37
37
  import { navFromMeta } from "./nav-from-meta.js";
38
38
  import { ComposedPage } from "./pages/composed-page.js";
39
+ import { ProjectLogo } from "./project-logo.js";
39
40
  import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
40
41
  import { PluginNotice } from "../plugins/plugin-notice.js";
41
42
  import { RecordPage } from "./record-page.js";
@@ -178,6 +179,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
178
179
  onSignOut: user && (() => void signOut()),
179
180
  signIn: user ? void 0 : signIn.link(),
180
181
  workspace: current?.organization?.name ?? workspace,
182
+ logo: ProjectLogo,
181
183
  navGroups: groups,
182
184
  bottomBar: bottomBarFromMeta(meta, basePath, place),
183
185
  userMenu: [
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>.c{fill:#FF5A1F}.r{fill:#14161A}@media (prefers-color-scheme:dark){.c{fill:#FF6A33}.r{fill:#F1F0EA}}</style><path class="c" fill-rule="evenodd" d="M0 5a5 5 0 0 1 5-5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zM4 5a1 1 0 0 1 1-1h5v6H4z"/><path class="r" d="M17 0h10a5 5 0 0 1 5 5v22a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5V17h17z"/></svg>
@@ -0,0 +1,12 @@
1
+ //#region src/app/gallery/detail-fields.ts
2
+ /**
3
+ * The fields of a photo's details: those its record page shows in its sections, or without a layout every field but its
4
+ * key and tenant, less sensitive ones and those the gallery already shows: `omit`, such as the photo's file and the
5
+ * field pointing at the gallery's record.
6
+ */
7
+ const detailFields = (model, view, omit) => {
8
+ const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
9
+ return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive && !omit.includes(name));
10
+ };
11
+ //#endregion
12
+ export { detailFields };
@@ -13,5 +13,7 @@ const galleryTiles = (count) => {
13
13
  const swipeStep = (dx, dy) => Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy) ? 0 : dx < 0 ? 1 : -1;
14
14
  /** A key's step: the right arrow moves to the next photo, the left arrow to the previous one. */
15
15
  const keyStep = (key) => key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
16
+ /** The photo a step from `index` lands on among `count`: it stops at the first and the last rather than wrapping around. */
17
+ const steppedPhoto = (index, by, count) => Math.min(Math.max(index + by, 0), Math.max(count - 1, 0));
16
18
  //#endregion
17
- export { galleryTiles, keyStep, swipeStep };
19
+ export { galleryTiles, keyStep, steppedPhoto, swipeStep };
@@ -8,6 +8,7 @@ import { FileImage } from "../../live/file-image.js";
8
8
  import { Section } from "../../record-view/section.js";
9
9
  import { galleryTiles } from "./gallery-layout.js";
10
10
  import { Lightbox } from "./lightbox.js";
11
+ import { detailFields } from "./detail-fields.js";
11
12
  import { PhotoDetails } from "./photo-details.js";
12
13
  import { useGalleryPhotos } from "./use-gallery-photos.js";
13
14
  import { useState } from "react";
@@ -47,7 +48,7 @@ const Tile = ({ photo, large }) => {
47
48
  /**
48
49
  * A record's photos as tiles, one large and the next ones smaller, the last counting those not drawn ("+N"); a mobile
49
50
  * screen draws the large one with the number of photos. Each tile opens the lightbox at its photo. A photo held by a
50
- * record of a resource the user may read gets a details button there, whose details the record API answers. A record
51
+ * record of a resource the user may read has its details beside it there, as the record API answers them. A record
51
52
  * without photos has no gallery.
52
53
  */
53
54
  const GallerySection = ({ item, record, recordKey }) => {
@@ -71,13 +72,15 @@ const GallerySection = ({ item, record, recordKey }) => {
71
72
  const { shown, more } = galleryTiles(count);
72
73
  const holder = photos.holder;
73
74
  const holderView = holder && views[holder.name];
74
- const readable = holder !== void 0 && photoPermissions.read !== false;
75
+ const fields = holder ? detailFields(holder, holderView, [item.photos?.image, item.photos?.field].filter((name) => name !== void 0)) : [];
76
+ const readable = holder !== void 0 && photoPermissions.read !== false && fields.length > 0;
75
77
  const details = (index) => {
76
78
  const key = photos.items[index]?.key;
77
79
  return readable && key !== void 0 ? /* @__PURE__ */ jsx(PhotoDetails, {
78
80
  model: holder,
79
81
  view: holderView,
80
- photoKey: key
82
+ photoKey: key,
83
+ fields
81
84
  }, key) : void 0;
82
85
  };
83
86
  return /* @__PURE__ */ jsxs(Section, {
@@ -116,7 +119,7 @@ const GallerySection = ({ item, record, recordKey }) => {
116
119
  onIndex: setOpen,
117
120
  onClose: () => setOpen(void 0),
118
121
  details,
119
- detailsLabel: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
122
+ detailsTitle: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
120
123
  })]
121
124
  });
122
125
  };
@@ -2,25 +2,27 @@ import { cn } from "../../lib/cn.js";
2
2
  import { useFocusTrap } from "../../lib/use-focus-trap.js";
3
3
  import { shownImage } from "../../live/file-values.js";
4
4
  import { FileImage } from "../../live/file-image.js";
5
- import { shownPhoto } from "../displays/card-photos.js";
6
- import { keyStep, swipeStep } from "./gallery-layout.js";
7
- import { useRef, useState } from "react";
8
- import { ChevronLeft, ChevronRight, Info, X } from "lucide-react";
5
+ import { keyStep, steppedPhoto, swipeStep } from "./gallery-layout.js";
6
+ import { useRef } from "react";
7
+ import { ChevronLeft, ChevronRight, X } from "lucide-react";
9
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
9
  //#region src/app/gallery/lightbox.tsx
11
10
  const control = "flex size-10 shrink-0 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40";
12
11
  /**
13
12
  * Photos one at a time at full size over the page: a modal dialog with a counter, arrows and keys (left, right, Escape)
14
- * that step through them and wrap around, a sideways swipe on a touch screen, and, for a photo with details the user may
15
- * read, a button that shows them under the photo. The page does not scroll behind it, and focus returns to what opened it.
13
+ * that step through them, stopping at the first and the last, a sideways swipe on a touch screen, and, for a photo with details the user may
14
+ * read, those details in a sidebar beside it, or below it on a mobile screen, where the dialog scrolls down to them. The
15
+ * page does not scroll behind it, and focus returns to what opened it.
16
16
  */
17
- const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabel = "Details" }) => {
17
+ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitle = "Details" }) => {
18
18
  const panel = useRef(null);
19
19
  const start = useRef(void 0);
20
- const [showDetails, setShowDetails] = useState(false);
21
20
  useFocusTrap(panel, true, onClose);
22
21
  const count = photos.length;
23
- const step = (by) => by !== 0 && onIndex(shownPhoto(index + by, count));
22
+ const step = (by) => {
23
+ const next = steppedPhoto(index, by, count);
24
+ if (next !== index) onIndex(next);
25
+ };
24
26
  const image = shownImage(photos[index], {
25
27
  width: 1600,
26
28
  height: 1200
@@ -50,39 +52,26 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
50
52
  "aria-modal": "true",
51
53
  "aria-label": title,
52
54
  onKeyDown: key,
53
- className: "fixed inset-0 z-[90] flex flex-col bg-backdrop outline-none",
54
- children: [
55
- /* @__PURE__ */ jsxs("div", {
55
+ className: "fixed inset-0 z-[90] flex flex-col overflow-y-auto overscroll-contain bg-backdrop outline-none md:flex-row",
56
+ children: [/* @__PURE__ */ jsxs("div", {
57
+ className: cn("flex shrink-0 flex-col md:sticky md:top-0 md:h-dvh md:min-w-0 md:flex-1", shownDetails === void 0 ? "h-dvh" : "h-[85svh]"),
58
+ children: [/* @__PURE__ */ jsxs("div", {
56
59
  className: "flex shrink-0 items-center justify-between gap-2 p-3",
57
60
  children: [/* @__PURE__ */ jsx("span", {
58
61
  className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
59
62
  "aria-live": "polite",
60
63
  children: `${index + 1} / ${count}`
61
- }), /* @__PURE__ */ jsxs("span", {
62
- className: "flex items-center gap-2",
63
- children: [shownDetails !== void 0 && /* @__PURE__ */ jsx("button", {
64
- type: "button",
65
- className: cn(control, showDetails && "bg-background ring-2 ring-primary/40"),
66
- "aria-label": detailsLabel,
67
- "aria-pressed": showDetails,
68
- onClick: () => setShowDetails(!showDetails),
69
- children: /* @__PURE__ */ jsx(Info, {
70
- className: "size-4",
71
- "aria-hidden": true
72
- })
73
- }), /* @__PURE__ */ jsx("button", {
74
- type: "button",
75
- className: control,
76
- "aria-label": "Close",
77
- onClick: onClose,
78
- children: /* @__PURE__ */ jsx(X, {
79
- className: "size-4",
80
- "aria-hidden": true
81
- })
82
- })]
64
+ }), /* @__PURE__ */ jsx("button", {
65
+ type: "button",
66
+ className: control,
67
+ "aria-label": "Close",
68
+ onClick: onClose,
69
+ children: /* @__PURE__ */ jsx(X, {
70
+ className: "size-4",
71
+ "aria-hidden": true
72
+ })
83
73
  })]
84
- }),
85
- /* @__PURE__ */ jsxs("div", {
74
+ }), /* @__PURE__ */ jsxs("div", {
86
75
  className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
87
76
  onPointerDown: down,
88
77
  onPointerUp: up,
@@ -92,8 +81,9 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
92
81
  className: "max-h-full max-w-full select-none object-contain"
93
82
  }, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
94
83
  type: "button",
95
- className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2"),
84
+ className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
96
85
  "aria-label": "Previous photo",
86
+ "aria-disabled": index === 0,
97
87
  onClick: () => step(-1),
98
88
  children: /* @__PURE__ */ jsx(ChevronLeft, {
99
89
  className: "size-5",
@@ -101,20 +91,24 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
101
91
  })
102
92
  }), /* @__PURE__ */ jsx("button", {
103
93
  type: "button",
104
- className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2"),
94
+ className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
105
95
  "aria-label": "Next photo",
96
+ "aria-disabled": index === count - 1,
106
97
  onClick: () => step(1),
107
98
  children: /* @__PURE__ */ jsx(ChevronRight, {
108
99
  className: "size-5",
109
100
  "aria-hidden": true
110
101
  })
111
102
  })] })]
112
- }),
113
- showDetails && shownDetails !== void 0 && /* @__PURE__ */ jsx("div", {
114
- className: "shrink-0 border-t border-border bg-background p-4 text-foreground",
115
- children: shownDetails
116
- })
117
- ]
103
+ })]
104
+ }), shownDetails !== void 0 && /* @__PURE__ */ jsxs("aside", {
105
+ "aria-label": detailsTitle,
106
+ className: "shrink-0 border-t border-border bg-background p-5 text-foreground md:min-h-dvh md:w-[360px] md:border-l md:border-t-0",
107
+ children: [/* @__PURE__ */ jsx("h2", {
108
+ className: "mb-3 text-[15px] font-semibold",
109
+ children: detailsTitle
110
+ }), shownDetails]
111
+ })]
118
112
  });
119
113
  };
120
114
  //#endregion
@@ -6,17 +6,13 @@ import { ErrorBanner } from "../error-banner.js";
6
6
  import { fieldAccess } from "../permissions.js";
7
7
  import { SpecTable } from "../../record-view/spec-table.js";
8
8
  import { FieldValue } from "../pages/field-value.js";
9
- import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app/gallery/photo-details.tsx
11
- const detailFields = (model, view) => {
12
- const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
13
- return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive);
14
- };
15
11
  /**
16
- * A photo's record, as the record API answers the user, so the server decides what they may read: the fields its
17
- * record page shows, as label/value rows, and a link to that page.
12
+ * A photo's record, as the record API answers the user, so the server decides what they may read: its `fields` (see
13
+ * `detailFields`), as label/value rows, and a link to its page.
18
14
  */
19
- const PhotoDetails = ({ model, view, photoKey }) => {
15
+ const PhotoDetails = ({ model, view, photoKey, fields }) => {
20
16
  const record = useRecord(model.name, photoKey);
21
17
  const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
22
18
  if (record.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" });
@@ -24,26 +20,23 @@ const PhotoDetails = ({ model, view, photoKey }) => {
24
20
  title: `Could not load the ${singular}`,
25
21
  error: record.error
26
22
  });
27
- const shown = detailFields(model, view).filter((name) => fieldAccess(record.data.permissions, name) !== "hidden");
28
- return /* @__PURE__ */ jsxs("div", {
29
- className: "mx-auto max-w-2xl",
30
- children: [/* @__PURE__ */ jsx(SpecTable, { rows: shown.map((name) => ({
31
- label: view?.fields[name]?.label ?? humanize(name),
32
- value: /* @__PURE__ */ jsx(FieldValue, {
33
- model,
34
- field: model.fields[name],
35
- value: record.data.record[name]
36
- })
37
- })) }), /* @__PURE__ */ jsx(AppLink, {
38
- to: {
39
- kind: "record",
40
- resource: model.name,
41
- key: photoKey
42
- },
43
- className: "mt-3 inline-block text-[13px] font-medium text-primary-text hover:underline",
44
- children: `Open ${singular}`
45
- })]
46
- });
23
+ const shown = fields.filter((name) => fieldAccess(record.data.permissions, name) !== "hidden");
24
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SpecTable, { rows: shown.map((name) => ({
25
+ label: view?.fields[name]?.label ?? humanize(name),
26
+ value: /* @__PURE__ */ jsx(FieldValue, {
27
+ model,
28
+ field: model.fields[name],
29
+ value: record.data.record[name]
30
+ })
31
+ })) }), /* @__PURE__ */ jsx(AppLink, {
32
+ to: {
33
+ kind: "record",
34
+ resource: model.name,
35
+ key: photoKey
36
+ },
37
+ className: "mt-3 inline-block text-[13px] font-medium text-primary-text hover:underline",
38
+ children: `Open ${singular}`
39
+ })] });
47
40
  };
48
41
  //#endregion
49
42
  export { PhotoDetails };
@@ -3,7 +3,7 @@ import { ProjectUi } from "./project-ui.js";
3
3
  /**
4
4
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
5
5
  * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
6
- * and `beforeSignIn` component is drawn, the extended configs' first.
6
+ * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
7
7
  */
8
8
  export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
9
9
  //#endregion
@@ -36,7 +36,7 @@ const around = (wrappers) => {
36
36
  /**
37
37
  * One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
38
38
  * handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
39
- * and `beforeSignIn` component is drawn, the extended configs' first.
39
+ * and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
40
40
  */
41
41
  const mergeUi = (...uis) => {
42
42
  const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
@@ -45,6 +45,7 @@ const mergeUi = (...uis) => {
45
45
  const fields = union("The field type", named, (ui) => ui.fields);
46
46
  const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
47
47
  const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
48
+ const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
48
49
  const beforeSignIn = around(named.flatMap(({ ui }) => ui.beforeSignIn ? [ui.beforeSignIn] : []));
49
50
  const name = uis.at(-1)?.name;
50
51
  return {
@@ -52,9 +53,10 @@ const mergeUi = (...uis) => {
52
53
  components,
53
54
  actions,
54
55
  ...Object.keys(fields).length > 0 && { fields },
55
- ...(actionSlot || rightPanel) && { shell: {
56
+ ...(actionSlot || rightPanel || logo) && { shell: {
56
57
  ...actionSlot && { actions: actionSlot },
57
- ...rightPanel && { rightPanel }
58
+ ...rightPanel && { rightPanel },
59
+ ...logo && { logo }
58
60
  } },
59
61
  ...beforeSignIn && { beforeSignIn }
60
62
  };
@@ -1,3 +1,4 @@
1
+ import { ShellLogoProps } from "../../app-shell/logo.js";
1
2
  import { FieldWidget } from "../field-types/field-widget.js";
2
3
  import { ComponentType, ReactNode } from "react";
3
4
  import { Client } from "@protobase/client";
@@ -48,10 +49,14 @@ export type ProjectUi = {
48
49
  actions?: Record<string, ActionHandler>;
49
50
  /** Field types by name, each with the storage types it shows and its components (see `FieldWidget`). */
50
51
  fields?: Record<string, FieldWidget>;
51
- /** Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page. */
52
+ /**
53
+ * Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, and `logo`
54
+ * at the top of the sidebar in place of Protobase's (see `ShellLogoProps`).
55
+ */
52
56
  shell?: {
53
57
  actions?: ComponentType;
54
58
  rightPanel?: ComponentType;
59
+ logo?: ComponentType<ShellLogoProps>;
55
60
  };
56
61
  /**
57
62
  * Drawn around the pages someone signed out sees, only for a plugin the app loads before sign-in (`beforeSignIn` in
@@ -1,13 +1,18 @@
1
- import { useAppAddress } from "../use-app-address.js";
1
+ import { AppLink, useAppAddress } from "../use-app-address.js";
2
2
  import { ActionRail } from "../../record-view/action-rail.js";
3
3
  import { ConfirmAction } from "./confirm-action.js";
4
4
  import { useActionRunner } from "./use-run-action.js";
5
5
  import { actionIcon } from "../action-icon.js";
6
6
  import { useState } from "react";
7
+ import { Pencil, Trash2 } from "lucide-react";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
9
  //#region src/app/pages/record-actions.tsx
9
- /** The record page's named actions, run like the Action block runs them; deleting the record returns to the list. */
10
- const RecordActions = ({ model, stored, recordKey }) => {
10
+ /**
11
+ * The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
12
+ * actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
13
+ * `onDelete`, unless a named action deletes already.
14
+ */
15
+ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
11
16
  const { go } = useAppAddress();
12
17
  const scope = {
13
18
  model,
@@ -21,14 +26,35 @@ const RecordActions = ({ model, stored, recordKey }) => {
21
26
  resource: model.name
22
27
  }, { replace: true }));
23
28
  const [asking, setAsking] = useState();
24
- if (runner.actions.length === 0) return null;
25
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions: runner.actions.map((action) => ({
29
+ const named = runner.actions.map((action) => ({
26
30
  name: action.label,
27
31
  icon: actionIcon(action.icon),
28
32
  blocked: runner.blocked(action),
29
33
  running: runner.running === action.name,
34
+ ...action.run?.kind === "delete" && { tone: "danger" },
30
35
  onRun: () => action.confirm ? setAsking(action) : void runner.run(action)
31
- })) }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
36
+ }));
37
+ const deletes = runner.actions.some((action) => action.run?.kind === "delete");
38
+ const actions = [
39
+ ...edit ? [{
40
+ name: "Edit",
41
+ icon: Pencil,
42
+ render: ({ className, children }) => /* @__PURE__ */ jsx(AppLink, {
43
+ to: edit,
44
+ className,
45
+ children
46
+ })
47
+ }] : [],
48
+ ...named,
49
+ ...onDelete && !deletes ? [{
50
+ name: "Delete",
51
+ icon: Trash2,
52
+ tone: "danger",
53
+ onRun: onDelete
54
+ }] : []
55
+ ];
56
+ if (actions.length === 0) return null;
57
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
32
58
  action: asking,
33
59
  label: asking.label,
34
60
  danger: asking.run?.kind === "delete",
@@ -0,0 +1,17 @@
1
+ import { Logo } from "../app-shell/logo.js";
2
+ import { CustomBoundary } from "./pages/custom-boundary.js";
3
+ import { useProjectUi } from "./pages/project-ui.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/project-logo.tsx
6
+ /** The project's `shell.logo` at the top of the sidebar, or Protobase's while it has none or when it throws. */
7
+ const ProjectLogo = (props) => {
8
+ const Own = useProjectUi().shell?.logo;
9
+ if (!Own) return /* @__PURE__ */ jsx(Logo, { ...props });
10
+ return /* @__PURE__ */ jsx(CustomBoundary, {
11
+ name: "shell.logo",
12
+ fallback: /* @__PURE__ */ jsx(Logo, { ...props }),
13
+ children: /* @__PURE__ */ jsx(Own, { ...props })
14
+ });
15
+ };
16
+ //#endregion
17
+ export { ProjectLogo };
@@ -1,18 +1,23 @@
1
1
  //#region src/app/record-mode/record-mode.ts
2
2
  /**
3
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.
4
+ * asks for the form (`edit`). A user who may not change the record gets the read view in every mode, at any address.
5
+ * To one who may, always editable (`edit`, the default) shows the form and ignores `edit`; `read` and `locked` show the
6
+ * read view or the locked form until the address asks for the form. `offersEdit` is whether the page links to the
7
+ * form; `editing` whether the record is being edited, which decides the help shown only while editing.
8
8
  */
9
9
  const recordShown = (mode, canUpdate, edit) => {
10
+ if (!canUpdate) return {
11
+ shown: "read",
12
+ offersEdit: false,
13
+ editing: false
14
+ };
10
15
  if (mode === void 0 || mode === "edit") return {
11
- shown: canUpdate ? "form" : "locked",
16
+ shown: "form",
12
17
  offersEdit: false,
13
- editing: canUpdate
18
+ editing: true
14
19
  };
15
- if (canUpdate && edit) return {
20
+ if (edit) return {
16
21
  shown: "form",
17
22
  offersEdit: false,
18
23
  editing: true
@@ -1,7 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { inApp } from "../app-shell/breadcrumb.js";
3
3
  import { useAuth } from "../auth/auth-provider.js";
4
- import { formatRelativeTime } from "../format/date.js";
5
4
  import { Button, buttonClass } from "../primitives/button.js";
6
5
  import { keys } from "../data/query-keys.js";
7
6
  import { useClient } from "../data/api-provider.js";
@@ -42,7 +41,7 @@ import { useRecordEditor } from "./use-record-editor.js";
42
41
  import { useRelatedRecords } from "./use-related-records.js";
43
42
  import { RelatedSection } from "./related-section.js";
44
43
  import { Fragment, useMemo } from "react";
45
- import { Check, Pencil } from "lucide-react";
44
+ import { Check } from "lucide-react";
46
45
  import { isApiError } from "@protobase/client";
47
46
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
48
47
  import { useQueryClient } from "@tanstack/react-query";
@@ -50,9 +49,8 @@ import { enumLabel } from "@protobase/schema";
50
49
  //#region src/app/record-page.tsx
51
50
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
52
51
  /**
53
- * The view's layout, or one section with the editable fields when the view has none. A user who may only read the
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.
52
+ * The view's layout, or one section with the editable fields when the view has none. The read view, which is all a
53
+ * user who may only read the resource gets, shows every field: the record rather than what can be changed.
56
54
  */
57
55
  const layoutFor = (model, view, read) => {
58
56
  if (view && view.layout.length > 0) return view.layout;
@@ -148,9 +146,10 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
148
146
  const { fields: fieldTypes } = useProjectUi();
149
147
  const layout = layoutFor(model, view, shown === "read");
150
148
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
151
- const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
149
+ const hasStatus = model.fields.status?.type === "enum";
150
+ const statusValue = stored.record.status;
151
+ const status = hasStatus && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
152
152
  const updatedAt = auditField(model, "updated");
153
- const updated = updatedAt && stored.record[updatedAt] ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record[updatedAt]))) / 1e3)) : void 0;
154
153
  const hint = (name) => view?.fields[name];
155
154
  const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
156
155
  const fieldView = (name) => {
@@ -251,30 +250,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
251
250
  const save = async () => {
252
251
  if (await editor.save() && leavesForm) go(readAddress, { replace: true });
253
252
  };
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: [
253
+ const startDelete = canDelete ? () => void remove.start({
254
+ key: recordKey,
255
+ title,
256
+ etag: stored.etag,
257
+ snapshot: stored.record
258
+ }) : void 0;
259
+ const formControls = shown === "form" && /* @__PURE__ */ jsxs(Fragment$1, { children: [
273
260
  editor.dirty && /* @__PURE__ */ jsx("span", {
274
261
  className: "text-xs text-muted-foreground",
275
262
  children: "Unsaved changes"
276
263
  }),
277
- editor.dirty && canUpdate && !leavesForm && /* @__PURE__ */ jsx(Button, {
264
+ editor.dirty && !leavesForm && /* @__PURE__ */ jsx(Button, {
278
265
  className: "max-md:min-h-11",
279
266
  disabled: editor.saving,
280
267
  onClick: editor.discard,
@@ -286,8 +273,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
286
273
  onClick: (event) => inApp(event) && editor.discard(),
287
274
  children: "Cancel"
288
275
  }),
289
- deleteButton,
290
- canUpdate && /* @__PURE__ */ jsxs(Button, {
276
+ /* @__PURE__ */ jsxs(Button, {
291
277
  variant: "primary",
292
278
  className: "max-md:min-h-11 max-md:flex-1",
293
279
  loading: editor.saving,
@@ -306,16 +292,17 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
306
292
  className: "mx-auto max-w-[1100px] px-4 pb-8 pt-6 md:px-10 md:py-8",
307
293
  children: [
308
294
  /* @__PURE__ */ jsx(RecordHeader, {
309
- collection: view?.names?.plural ?? humanize(model.name),
310
295
  title,
311
- status: {
312
- label: status ? enumLabel(status, hint("status")?.valueLabels) : singular,
313
- tone: status ? toneFor(status) : "neutral"
296
+ status: status ? {
297
+ label: enumLabel(status, hint("status")?.valueLabels),
298
+ tone: toneFor(status)
299
+ } : hasStatus ? void 0 : {
300
+ label: singular,
301
+ tone: "neutral"
314
302
  },
315
- lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
316
303
  actions: /* @__PURE__ */ jsx("div", {
317
304
  className: "hidden items-center gap-4 md:flex",
318
- children: saveControls
305
+ children: formControls
319
306
  })
320
307
  }),
321
308
  editor.message && /* @__PURE__ */ jsx("div", {
@@ -411,7 +398,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
411
398
  /* @__PURE__ */ jsx(RecordActions, {
412
399
  model,
413
400
  stored,
414
- recordKey
401
+ recordKey,
402
+ edit: offersEdit ? editAddress : void 0,
403
+ onDelete: startDelete
415
404
  }),
416
405
  firstSidebar === -1 && summary,
417
406
  layout.map((item, index) => {
@@ -448,9 +437,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
448
437
  ]
449
438
  })
450
439
  }),
451
- (editor.dirty || canDelete || canUpdate) && /* @__PURE__ */ jsx("div", {
440
+ formControls && /* @__PURE__ */ jsx("div", {
452
441
  className: "sticky bottom-[var(--pb-sticky-bottom,0px)] z-20 flex shrink-0 items-center gap-2 border-t bg-background p-3 md:hidden",
453
- children: saveControls
442
+ children: formControls
454
443
  })
455
444
  ]
456
445
  });
@@ -4,4 +4,4 @@ import "./nav.js";
4
4
  import "./profile-menu.js";
5
5
  import "./global-search.js";
6
6
  import "./breadcrumb.js";
7
- import { ReactNode } from "react";
7
+ import { ComponentType, ReactNode } from "react";
@@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react";
9
9
  import { jsx, jsxs } from "react/jsx-runtime";
10
10
  //#region src/app-shell/app-shell.tsx
11
11
  const drawerId = "app-drawer";
12
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
12
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
13
13
  const desktop = useIsDesktop();
14
14
  const [drawerOpen, setDrawerOpen] = useState(false);
15
15
  useEffect(() => {
@@ -37,7 +37,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
37
37
  signIn,
38
38
  organizations,
39
39
  roles,
40
- theme
40
+ theme,
41
+ logo
41
42
  });
42
43
  return /* @__PURE__ */ jsx(PageScrollContext.Provider, {
43
44
  value: setPageScroll,
@@ -4,6 +4,7 @@ import "./profile-menu.js";
4
4
  import { SidebarMode, sidebarModes } from "./sidebar-mode.js";
5
5
  import "./global-search.js";
6
6
  import "./breadcrumb.js";
7
+ import { LogoMark, ShellLogoProps } from "./logo.js";
7
8
  import "./app-shell.js";
8
9
  import "./page-header.js";
9
10
  import "./page-scroll.js";